Merge "PolyGerrit: Add some more missing configs to gr-project"
diff --git a/.gitignore b/.gitignore
index 06a6e66..0e954ce 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,4 @@
 /plugins/cookbook-plugin/
 /test_site
 /tools/format
+/.vscode
\ No newline at end of file
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 2c5acc2..73fd687 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -740,6 +740,9 @@
 * `"diff"`: default is `10m` (10 MiB of memory)
 * `"diff_intraline"`: default is `10m` (10 MiB of memory)
 * `"diff_summary"`: default is `10m` (10 MiB of memory)
+* `"groups"`: default is unlimited
+* `"groups_byname"`: default is unlimited
+* `"groups_byuuid"`: default is unlimited
 * `"plugin_resources"`: default is 2m (2 MiB of memory)
 
 +
@@ -841,9 +844,40 @@
 
 cache `"groups"`::
 +
-Caches the basic group information from the `account_groups` table,
+Caches the basic group information of internal groups by group ID,
 including the group owner, name, and description.
 +
+For this cache it is important to configure a size that is larger than
+the number of internal Gerrit groups, otherwise general Gerrit
+performance may be poor. This is why by default this cache is
+unlimited.
++
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
+
+cache `"groups_byname"`::
++
+Caches the basic group information of internal groups by group name,
+including the group owner, name, and description.
++
+For this cache it is important to configure a size that is larger than
+the number of internal Gerrit groups, otherwise general Gerrit
+performance may be poor. This is why by default this cache is
+unlimited.
++
+External group membership obtained from LDAP is cached under
+`"ldap_groups"`.
+
+cache `"groups_byuuid"`::
++
+Caches the basic group information of internal groups by group UUID,
+including the group owner, name, and description.
++
+For this cache it is important to configure a size that is larger than
+the number of internal Gerrit groups, otherwise general Gerrit
+performance may be poor. This is why by default this cache is
+unlimited.
++
 External group membership obtained from LDAP is cached under
 `"ldap_groups"`.
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index dada623..8774ed2 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2249,7 +2249,7 @@
 link:rest-api-accounts.html#create-account[account creation] REST API and
 inject additional external identifiers for an account that represents a user
 in some external user store. For that, an implementation of the extension
-point `com.google.gerrit.server.api.accounts.AccountExternalIdCreator`
+point `com.google.gerrit.server.account.AccountExternalIdCreator`
 must be registered.
 
 [source,java]
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 66c67dc..c4017f3 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -42,7 +42,7 @@
 
 Basically, the DOM is the API surface. Low-level API provides methods for
 decorating, replacing, and styling DOM elements exposed through a set of
-endpoints.
+link:pg-plugin-endpoints.html[endpoints].
 
 PolyGerrit provides a simple way for accessing the DOM via DOM hooks API. A DOM
 hook is a custom element that is instantiated for the plugin endpoint. In the
@@ -70,8 +70,6 @@
 For each endpoint, PolyGerrit provides a list of DOM properties (such as
 attributes and events) that are supported in the long-term.
 
-NOTE: TODO: Insert link to the full endpoints API.
-
 ``` js
 Gerrit.install(plugin => {
   const domHook = plugin.hook('reply-text');
@@ -159,6 +157,7 @@
 === hook
 `plugin.hook(endpointName, opt_options)`
 
+See list of supported link:pg-plugin-endpoints.html[endpoints].
 Note: TODO
 
 === registerCustomComponent
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
new file mode 100644
index 0000000..7c960dd
--- /dev/null
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -0,0 +1,69 @@
+= Gerrit Code Review - PolyGerrit Plugin Styling
+
+Plugin should be html-based and imported following PolyGerrit's
+link:pg-plugin-dev.html#loading[dev guide].
+
+Sample code for testing endpoints:
+
+``` js
+Gerrit.install(plugin => {
+  // Change endpoint below
+  const endpoint = 'change-metadata-item';
+  plugin.hook(endpoint).onAttached(element => {
+    console.log(endpoint, element);
+    const el = element.appendChild(document.createElement('div'));
+    el.textContent = 'Ah, there it is. Lovely.';
+    el.style = 'background: pink; line-height: 4em; text-align: center;';
+  });
+});
+```
+
+== Default parameters
+All endpoints receive the following params, set as attributes to custom components
+that are instantiated at the endpoint:
+
+* `plugin`
++
+the current plugin instance, the one that is used by `Gerrit.install()`.
+
+* `content`
++
+decorated DOM Element, is only set for registrations that decorate existing
+components.
+
+== Plugin endpoints
+
+Following endpoints are available to plugins
+
+=== change-view-integration
+Extension point is located between `Files` and `Messages` section on the change
+view page, and it may take full page's width. Primary purpose is to enable
+plugins to display custom CI related information (build status, etc).
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
+
+=== change-metadata-item
+Extension point is located on the bottom of the change view left panel, under
+`Label Status` and `Links` sections. It's width is equal to the left panel's and
+primary purpose is to enable plugins to add sections of metadata to the left
+panel.
+
+In addition to default parameters, the following are available:
+
+* `change`
++
+current change displayed, an instance of
+link:rest-api-changes.html#change-info[ChangeInfo]
+
+* `revision`
++
+current revision displayed, an instance of
+link:rest-api-changes.html#revision-info[RevisionInfo]
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 5c1a29e..2cc78a4 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -324,6 +324,33 @@
   }
 ----
 
+All::
+Get all projects, including those whose state is "HIDDEN".
++
+.Request
+----
+GET /projects/?all HTTP/1.0
+----
++
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "All-Projects" {
+      "id": "All-Projects",
+      "state": "ACTIVE"
+    },
+    "some-other-project": {
+      "id": "some-other-project",
+      "state": "HIDDEN"
+    }
+  }
+----
+
 [[query-projects]]
 === Query Projects
 --
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 8d50073..9566e0f 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -39,12 +39,14 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
@@ -80,6 +82,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.change.Abandon;
 import com.google.gerrit.server.change.ChangeResource;
@@ -220,6 +223,7 @@
   @Inject protected FakeEmailSender sender;
   @Inject protected GerritApi gApi;
   @Inject protected GitRepositoryManager repoManager;
+  @Inject protected GroupBackend groupBackend;
   @Inject protected GroupCache groupCache;
   @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
@@ -346,9 +350,9 @@
     // later on. As test indexes are non-permanent, closing an instance and opening another one
     // removes all indexed data.
     // As a workaround, we simply reindex all available groups here.
-    Iterable<AccountGroup.UUID> allGroupUuids = groups.getAllUuids(db)::iterator;
-    for (AccountGroup.UUID groupUuid : allGroupUuids) {
-      groupCache.onCreateGroup(groupUuid);
+    Iterable<GroupReference> allGroups = groups.getAllGroupReferences(db)::iterator;
+    for (GroupReference group : allGroups) {
+      groupCache.onCreateGroup(group.getUUID());
     }
 
     admin = accountCreator.admin();
@@ -917,9 +921,7 @@
 
   protected void grant(Project.NameKey project, String ref, String permission, boolean force)
       throws RepositoryNotFoundException, IOException, ConfigInvalidException {
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(project, ref, permission, force, adminGroup.getGroupUUID());
+    grant(project, ref, permission, force, adminGroupUuid());
   }
 
   protected void grant(
@@ -1121,8 +1123,7 @@
       String g = createGroup("cla-test-group");
       GroupApi groupApi = gApi.groups().id(g);
       groupApi.description("CLA test group");
-      InternalGroup caGroup =
-          groupCache.get(new AccountGroup.UUID(groupApi.detail().id)).orElse(null);
+      InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
       GroupReference groupRef = new GroupReference(caGroup.getGroupUUID(), caGroup.getName());
       PermissionRule rule = new PermissionRule(groupRef);
       rule.setAction(PermissionRule.Action.ALLOW);
@@ -1290,6 +1291,113 @@
     }
   }
 
+  protected void assertPermissions(
+      Project.NameKey project,
+      GroupReference groupReference,
+      String ref,
+      boolean exclusive,
+      String... permissionNames)
+      throws IOException {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccessSection accessSection = cfg.getAccessSection(ref);
+    assertThat(accessSection).isNotNull();
+    for (String permissionName : permissionNames) {
+      Permission permission = accessSection.getPermission(permissionName);
+      assertPermission(permission, permissionName, exclusive, null);
+      assertPermissionRule(
+          permission.getRule(groupReference), groupReference, Action.ALLOW, false, 0, 0);
+    }
+  }
+
+  protected void assertLabelPermission(
+      Project.NameKey project,
+      GroupReference groupReference,
+      String ref,
+      boolean exclusive,
+      String labelName,
+      int min,
+      int max)
+      throws IOException {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccessSection accessSection = cfg.getAccessSection(ref);
+    assertThat(accessSection).isNotNull();
+
+    String permissionName = Permission.LABEL + labelName;
+    Permission permission = accessSection.getPermission(permissionName);
+    assertPermission(permission, permissionName, exclusive, labelName);
+    assertPermissionRule(
+        permission.getRule(groupReference), groupReference, Action.ALLOW, false, min, max);
+  }
+
+  private void assertPermission(
+      Permission permission,
+      String expectedName,
+      boolean expectedExclusive,
+      @Nullable String expectedLabelName) {
+    assertThat(permission).isNotNull();
+    assertThat(permission.getName()).isEqualTo(expectedName);
+    assertThat(permission.getExclusiveGroup()).isEqualTo(expectedExclusive);
+    assertThat(permission.getLabel()).isEqualTo(expectedLabelName);
+  }
+
+  private void assertPermissionRule(
+      PermissionRule rule,
+      GroupReference expectedGroupReference,
+      Action expectedAction,
+      boolean expectedForce,
+      int expectedMin,
+      int expectedMax) {
+    assertThat(rule.getGroup()).isEqualTo(expectedGroupReference);
+    assertThat(rule.getAction()).isEqualTo(expectedAction);
+    assertThat(rule.getForce()).isEqualTo(expectedForce);
+    assertThat(rule.getMin()).isEqualTo(expectedMin);
+    assertThat(rule.getMax()).isEqualTo(expectedMax);
+  }
+
+  protected InternalGroup group(AccountGroup.UUID groupUuid) {
+    InternalGroup group = groupCache.get(groupUuid).orElse(null);
+    assertThat(group).named(groupUuid.get()).isNotNull();
+    return group;
+  }
+
+  protected GroupReference groupRef(AccountGroup.UUID groupUuid) {
+    GroupDescription.Basic groupDescription = groupBackend.get(groupUuid);
+    return new GroupReference(groupDescription.getGroupUUID(), groupDescription.getName());
+  }
+
+  protected InternalGroup group(String groupName) {
+    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    assertThat(group).named(groupName).isNotNull();
+    return group;
+  }
+
+  protected GroupReference groupRef(String groupName) {
+    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    assertThat(group).isNotNull();
+    return new GroupReference(group.getGroupUUID(), group.getName());
+  }
+
+  protected AccountGroup.UUID groupUuid(String groupName) {
+    return group(groupName).getGroupUUID();
+  }
+
+  protected InternalGroup adminGroup() {
+    return group("Administrators");
+  }
+
+  protected GroupReference adminGroupRef() {
+    return groupRef("Administrators");
+  }
+
+  protected AccountGroup.UUID adminGroupUuid() {
+    return groupUuid("Administrators");
+  }
+
+  protected void assertGroupDoesNotExist(String groupName) {
+    InternalGroup group = groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null);
+    assertThat(group).named(groupName).isNull();
+  }
+
   protected void assertNotifyTo(TestAccount expected) {
     assertNotifyTo(expected.emailAddress);
   }
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 636e909..b29922f 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.GroupNoteDbMode;
 import com.google.gerrit.testing.NoteDbChecker;
 import com.google.gerrit.testing.NoteDbMode;
 import com.google.gerrit.testing.SshMode;
@@ -391,6 +392,7 @@
     cfg.setBoolean("index", null, "reindexAfterRefUpdate", false);
 
     NoteDbMode.newNotesMigrationFromEnv().setConfigValues(cfg);
+    GroupNoteDbMode.get().getGroupsMigration().setConfigValuesIfNotSetYet(cfg);
   }
 
   private static Injector createTestInjector(Daemon daemon) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index 3f2cbda..4b1211b 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.TrackingFootersProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -78,6 +79,7 @@
     bind(DataSourceType.class).to(InMemoryH2Type.class);
 
     install(new NotesMigration.Module());
+    install(new GroupsMigration.Module());
     TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
         new TypeLiteral<SchemaFactory<ReviewDb>>() {};
     bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 5f3c1ec..919f532 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -35,7 +35,6 @@
         ["**/*.java"],
         exclude = ANNOTATIONS,
     ),
-    resources = [":Version"],
     visibility = ["//visibility:public"],
     deps = [
         ":annotations",
@@ -52,6 +51,15 @@
     ],
 )
 
+# ":version" should not be in the dependency graph of the acceptance
+# tests to avoid spurious test re-runs. That's because the content of
+# //:version.txt is changed when the outcome of `git describe` is changed.
+java_library(
+    name = "version",
+    resources = [":Version"],
+    visibility = ["//visibility:public"],
+)
+
 genrule(
     name = "gen_version",
     srcs = ["//:version.txt"],
diff --git a/java/com/google/gerrit/common/TimeUtil.java b/java/com/google/gerrit/common/TimeUtil.java
index b1697dc..7f53f84 100644
--- a/java/com/google/gerrit/common/TimeUtil.java
+++ b/java/com/google/gerrit/common/TimeUtil.java
@@ -18,6 +18,10 @@
 import com.google.common.annotations.VisibleForTesting;
 import java.sql.Timestamp;
 import java.util.function.LongSupplier;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.SystemReader;
 
 /** Static utility methods for dealing with dates and times. */
 @GwtIncompatible("Unemulated Java 8 functionalities")
@@ -36,18 +40,67 @@
     return new Timestamp(nowMs());
   }
 
-  public static Timestamp roundToSecond(Timestamp t) {
+  public static Timestamp truncateToSecond(Timestamp t) {
     return new Timestamp((t.getTime() / 1000) * 1000);
   }
 
   @VisibleForTesting
   public static void setCurrentMillisSupplier(LongSupplier customCurrentMillisSupplier) {
     currentMillisSupplier = customCurrentMillisSupplier;
+
+    SystemReader oldSystemReader = SystemReader.getInstance();
+    if (!(oldSystemReader instanceof GerritSystemReader)) {
+      SystemReader.setInstance(new GerritSystemReader(oldSystemReader));
+    }
   }
 
   @VisibleForTesting
   public static void resetCurrentMillisSupplier() {
     currentMillisSupplier = SYSTEM_CURRENT_MILLIS_SUPPLIER;
+    SystemReader.setInstance(null);
+  }
+
+  private static class GerritSystemReader extends SystemReader {
+    SystemReader delegate;
+
+    GerritSystemReader(SystemReader delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public String getHostname() {
+      return delegate.getHostname();
+    }
+
+    @Override
+    public String getenv(String variable) {
+      return delegate.getenv(variable);
+    }
+
+    @Override
+    public String getProperty(String key) {
+      return delegate.getProperty(key);
+    }
+
+    @Override
+    public FileBasedConfig openUserConfig(Config parent, FS fs) {
+      return delegate.openUserConfig(parent, fs);
+    }
+
+    @Override
+    public FileBasedConfig openSystemConfig(Config parent, FS fs) {
+      return delegate.openSystemConfig(parent, fs);
+    }
+
+    @Override
+    public long getCurrentTime() {
+      return currentMillisSupplier.getAsLong();
+    }
+
+    @Override
+    public int getTimezone(long when) {
+      return delegate.getTimezone(when);
+    }
   }
 
   private TimeUtil() {}
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 8f0ea8f..3c471ac 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -14,39 +14,71 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
 import static java.util.stream.Collectors.toList;
 import static org.apache.commons.codec.binary.Base64.decodeBase64;
 import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.Index;
+import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
 import io.searchbox.client.JestResult;
 import io.searchbox.client.http.JestHttpClient;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Delete;
+import io.searchbox.core.Search;
+import io.searchbox.core.search.sort.Sort;
 import io.searchbox.indices.CreateIndex;
 import io.searchbox.indices.DeleteIndex;
 import io.searchbox.indices.IndicesExists;
 import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
 import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.function.Function;
+import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.lib.Config;
 import org.elasticsearch.common.xcontent.XContentBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
+  private static final Logger log = LoggerFactory.getLogger(AbstractElasticIndex.class);
+
   protected static <T> List<T> decodeProtos(
       JsonObject doc, String fieldName, ProtobufCodec<T> codec) {
     JsonArray field = doc.getAsJsonArray(fieldName);
@@ -146,7 +178,7 @@
 
   protected io.searchbox.core.Index insert(String type, V v) throws IOException {
     String id = getId(v);
-    String doc = toDoc(v);
+    String doc = toDocument(v);
     return new io.searchbox.core.Index.Builder(doc).index(indexName).type(type).id(id).build();
   }
 
@@ -154,7 +186,7 @@
     return !(element instanceof String) || !((String) element).isEmpty();
   }
 
-  private String toDoc(V v) throws IOException {
+  private String toDocument(V v) throws IOException {
     XContentBuilder builder = jsonBuilder().startObject();
     for (Values<V> values : schema.buildFields(v)) {
       String name = values.getField().getName();
@@ -171,4 +203,129 @@
     }
     return builder.endObject().string();
   }
+
+  protected abstract V fromDocument(JsonObject doc, Set<String> fields);
+
+  protected FieldBundle toFieldBundle(JsonObject doc) {
+    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
+    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
+    for (Entry<String, JsonElement> element : doc.get("fields").getAsJsonObject().entrySet()) {
+      checkArgument(
+          allFields.containsKey(element.getKey()), "Unrecognized field " + element.getKey());
+      FieldType<?> type = allFields.get(element.getKey()).getType();
+
+      Iterable<JsonElement> innerItems =
+          element.getValue().isJsonArray()
+              ? element.getValue().getAsJsonArray()
+              : Collections.singleton(element.getValue());
+
+      for (JsonElement inner : innerItems) {
+        if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
+          rawFields.put(element.getKey(), inner.getAsString());
+        } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
+          rawFields.put(element.getKey(), inner.getAsInt());
+        } else if (type == FieldType.LONG) {
+          rawFields.put(element.getKey(), inner.getAsLong());
+        } else if (type == FieldType.TIMESTAMP) {
+          rawFields.put(element.getKey(), new Timestamp(inner.getAsLong()));
+        } else if (type == FieldType.STORED_ONLY) {
+          rawFields.put(element.getKey(), Base64.decodeBase64(inner.getAsString()));
+        } else {
+          throw FieldType.badFieldType(type);
+        }
+      }
+    }
+    return new FieldBundle(rawFields);
+  }
+
+  protected class ElasticQuerySource implements DataSource<V> {
+    private final QueryOptions opts;
+    private final Search search;
+
+    ElasticQuerySource(Predicate<V> p, QueryOptions opts, String type, Sort sort)
+        throws QueryParseException {
+      this(p, opts, ImmutableList.of(type), ImmutableList.of(sort));
+    }
+
+    ElasticQuerySource(
+        Predicate<V> p, QueryOptions opts, Collection<String> types, Collection<Sort> sorts)
+        throws QueryParseException {
+      this.opts = opts;
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder()
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(opts.fields()));
+
+      search =
+          new Search.Builder(searchSource.toString())
+              .addType(types)
+              .addSort(sorts)
+              .addIndex(indexName)
+              .build();
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<V> read() throws OrmException {
+      return readImpl((doc) -> AbstractElasticIndex.this.fromDocument(doc, opts.fields()));
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      return readImpl(AbstractElasticIndex.this::toFieldBundle);
+    }
+
+    @Override
+    public String toString() {
+      return search.toString();
+    }
+
+    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) throws OrmException {
+      try {
+        List<T> results = Collections.emptyList();
+        JestResult result = client.execute(search);
+        if (result.isSucceeded()) {
+          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              T mapperResult = mapper.apply(json.get(i).getAsJsonObject());
+              if (mapperResult != null) {
+                results.add(mapperResult);
+              }
+            }
+          }
+        } else {
+          log.error(result.getErrorMessage());
+        }
+        final List<T> r = Collections.unmodifiableList(results);
+        return new ResultSet<T>() {
+          @Override
+          public Iterator<T> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<T> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
index 18eb660..ba5178e 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
@@ -16,9 +16,7 @@
 
 import static com.google.gerrit.server.index.account.AccountField.ID;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -33,30 +31,19 @@
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
-import io.searchbox.core.Search;
 import io.searchbox.core.search.sort.Sort;
 import io.searchbox.core.search.sort.Sort.Sorting;
 import java.io.IOException;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.index.query.QueryBuilder;
-import org.elasticsearch.search.builder.SearchSourceBuilder;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
     implements AccountIndex {
@@ -71,8 +58,6 @@
   static final String ACCOUNTS = "accounts";
   static final String ACCOUNTS_PREFIX = ACCOUNTS + "_";
 
-  private static final Logger log = LoggerFactory.getLogger(ElasticAccountIndex.class);
-
   private final AccountMapping mapping;
   private final Provider<AccountCache> accountCache;
 
@@ -109,7 +94,9 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    return new QuerySource(p, opts);
+    Sort sort = new Sort(AccountField.ID.getName(), Sorting.ASC);
+    sort.setIgnoreUnmapped();
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::accountFields), ACCOUNTS, sort);
   }
 
   @Override
@@ -128,92 +115,18 @@
     return as.getAccount().getId().toString();
   }
 
-  private class QuerySource implements DataSource<AccountState> {
-    private final Search search;
-    private final Set<String> fields;
-
-    QuerySource(Predicate<AccountState> p, QueryOptions opts) throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.accountFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      Sort sort = new Sort(AccountField.ID.getName(), Sorting.ASC);
-      sort.setIgnoreUnmapped();
-
-      search =
-          new Search.Builder(searchSource.toString())
-              .addType(ACCOUNTS)
-              .addIndex(indexName)
-              .addSort(ImmutableList.of(sort))
-              .build();
+  @Override
+  protected AccountState fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.get("_source");
+    if (source == null) {
+      source = json.getAsJsonObject().get("fields");
     }
 
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<AccountState> read() throws OrmException {
-      try {
-        List<AccountState> results = Collections.emptyList();
-        JestResult result = client.execute(search);
-        if (result.isSucceeded()) {
-          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              results.add(toAccountState(json.get(i)));
-            }
-          }
-        } else {
-          log.error(result.getErrorMessage());
-        }
-        final List<AccountState> r = Collections.unmodifiableList(results);
-        return new ResultSet<AccountState>() {
-          @Override
-          public Iterator<AccountState> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<AccountState> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public String toString() {
-      return search.toString();
-    }
-
-    private AccountState toAccountState(JsonElement json) {
-      JsonElement source = json.getAsJsonObject().get("_source");
-      if (source == null) {
-        source = json.getAsJsonObject().get("fields");
-      }
-
-      Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
-      // Use the AccountCache rather than depending on any stored fields in the
-      // document (of which there shouldn't be any). The most expensive part to
-      // compute anyway is the effective group IDs, and we don't have a good way
-      // to reindex when those change.
-      return accountCache.get().get(id);
-    }
+    Account.Id id = new Account.Id(source.getAsJsonObject().get(ID.getName()).getAsInt());
+    // Use the AccountCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any). The most expensive part to
+    // compute anyway is the effective group IDs, and we don't have a good way
+    // to reindex when those change.
+    return accountCache.get().get(id);
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 0fd2a77..b21d3df 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -53,38 +54,28 @@
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
-import io.searchbox.core.Search;
 import io.searchbox.core.search.sort.Sort;
 import io.searchbox.core.search.sort.Sort.Sorting;
 import java.io.IOException;
 import java.util.Collections;
-import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.index.query.QueryBuilder;
-import org.elasticsearch.search.builder.SearchSourceBuilder;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Secondary index implementation using Elasticsearch. */
 class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
     implements ChangeIndex {
-  private static final Logger log = LoggerFactory.getLogger(ElasticChangeIndex.class);
-
   static class ChangeMapping {
     MappingProperties openChanges;
     MappingProperties closedChanges;
@@ -153,7 +144,7 @@
   }
 
   @Override
-  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
       throws QueryParseException {
     Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
     List<String> indexes = Lists.newArrayListWithCapacity(2);
@@ -163,7 +154,16 @@
     if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
       indexes.add(CLOSED_CHANGES);
     }
-    return new QuerySource(indexes, p, opts);
+
+    List<Sort> sorts =
+        ImmutableList.of(
+            new Sort(ChangeField.UPDATED.getName(), Sorting.DESC),
+            new Sort(ChangeField.LEGACY_ID.getName(), Sorting.DESC));
+    for (Sort sort : sorts) {
+      sort.setIgnoreUnmapped();
+    }
+    QueryOptions filteredOpts = opts.filterFields(IndexUtils::changeFields);
+    return new ElasticQuerySource(p, filteredOpts, indexes, sorts);
   }
 
   @Override
@@ -181,297 +181,210 @@
     return cd.getId().toString();
   }
 
-  private class QuerySource implements ChangeDataSource {
-    private final Search search;
-    private final Set<String> fields;
+  @Override
+  protected ChangeData fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement sourceElement = json.get("_source");
+    if (sourceElement == null) {
+      sourceElement = json.getAsJsonObject().get("fields");
+    }
+    JsonObject source = sourceElement.getAsJsonObject();
+    JsonElement c = source.get(ChangeField.CHANGE.getName());
 
-    QuerySource(List<String> types, Predicate<ChangeData> p, QueryOptions opts)
-        throws QueryParseException {
-      List<Sort> sorts =
-          ImmutableList.of(
-              new Sort(ChangeField.UPDATED.getName(), Sorting.DESC),
-              new Sort(ChangeField.LEGACY_ID.getName(), Sorting.DESC));
-      for (Sort sort : sorts) {
-        sort.setIgnoreUnmapped();
+    if (c == null) {
+      int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
+      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
+      String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
+      return changeDataFactory.create(
+          db.get(), new Project.NameKey(projectName), new Change.Id(id));
+    }
+
+    ChangeData cd =
+        changeDataFactory.create(
+            db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
+
+    // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
+
+    // Patch sets.
+    cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
+
+    // Approvals.
+    if (source.get(ChangeField.APPROVAL.getName()) != null) {
+      cd.setCurrentApprovals(decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
+    } else if (fields.contains(ChangeField.APPROVAL.getName())) {
+      cd.setCurrentApprovals(Collections.emptyList());
+    }
+
+    // Added & Deleted.
+    JsonElement addedElement = source.get(ChangeField.ADDED.getName());
+    JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
+    if (addedElement != null && deletedElement != null) {
+      // Changed lines.
+      int added = addedElement.getAsInt();
+      int deleted = deletedElement.getAsInt();
+      if (added != 0 && deleted != 0) {
+        cd.setChangedLines(added, deleted);
       }
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.changeFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      search =
-          new Search.Builder(searchSource.toString())
-              .addType(types)
-              .addSort(sorts)
-              .addIndex(indexName)
-              .build();
     }
 
-    @Override
-    public int getCardinality() {
-      return 10;
+    // Mergeable.
+    JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
+    if (mergeableElement != null) {
+      String mergeable = mergeableElement.getAsString();
+      if ("1".equals(mergeable)) {
+        cd.setMergeable(true);
+      } else if ("0".equals(mergeable)) {
+        cd.setMergeable(false);
+      }
     }
 
-    @Override
-    public ResultSet<ChangeData> read() throws OrmException {
-      try {
-        List<ChangeData> results = Collections.emptyList();
-        JestResult result = client.execute(search);
-        if (result.isSucceeded()) {
-          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              results.add(toChangeData(json.get(i)));
-            }
+    // Reviewed-by.
+    if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
+      JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
+      if (reviewedBy.size() > 0) {
+        Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
+        for (int i = 0; i < reviewedBy.size(); i++) {
+          int aId = reviewedBy.get(i).getAsInt();
+          if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
+            break;
           }
-        } else {
-          log.error(result.getErrorMessage());
+          accounts.add(new Account.Id(aId));
         }
-        final List<ChangeData> r = Collections.unmodifiableList(results);
-        return new ResultSet<ChangeData>() {
-          @Override
-          public Iterator<ChangeData> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<ChangeData> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
+        cd.setReviewedBy(accounts);
       }
+    } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
+      cd.setReviewedBy(Collections.emptySet());
     }
 
-    @Override
-    public boolean hasChange() {
-      return false;
-    }
-
-    @Override
-    public String toString() {
-      return search.toString();
-    }
-
-    private ChangeData toChangeData(JsonElement json) {
-      JsonElement sourceElement = json.getAsJsonObject().get("_source");
-      if (sourceElement == null) {
-        sourceElement = json.getAsJsonObject().get("fields");
-      }
-      JsonObject source = sourceElement.getAsJsonObject();
-      JsonElement c = source.get(ChangeField.CHANGE.getName());
-
-      if (c == null) {
-        int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
-        // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-        String projectName = checkNotNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-        return changeDataFactory.create(
-            db.get(), new Project.NameKey(projectName), new Change.Id(id));
-      }
-
-      ChangeData cd =
-          changeDataFactory.create(
-              db.get(), CHANGE_CODEC.decode(Base64.decodeBase64(c.getAsString())));
-
-      // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
-
-      // Patch sets.
-      cd.setPatchSets(decodeProtos(source, ChangeField.PATCH_SET.getName(), PATCH_SET_CODEC));
-
-      // Approvals.
-      if (source.get(ChangeField.APPROVAL.getName()) != null) {
-        cd.setCurrentApprovals(
-            decodeProtos(source, ChangeField.APPROVAL.getName(), APPROVAL_CODEC));
-      } else if (fields.contains(ChangeField.APPROVAL.getName())) {
-        cd.setCurrentApprovals(Collections.emptyList());
-      }
-
-      // Added & Deleted.
-      JsonElement addedElement = source.get(ChangeField.ADDED.getName());
-      JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
-      if (addedElement != null && deletedElement != null) {
-        // Changed lines.
-        int added = addedElement.getAsInt();
-        int deleted = deletedElement.getAsInt();
-        if (added != 0 && deleted != 0) {
-          cd.setChangedLines(added, deleted);
+    // Hashtag.
+    if (source.get(ChangeField.HASHTAG.getName()) != null) {
+      JsonArray hashtagArray = source.get(ChangeField.HASHTAG.getName()).getAsJsonArray();
+      if (hashtagArray.size() > 0) {
+        Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtagArray.size());
+        for (int i = 0; i < hashtagArray.size(); i++) {
+          hashtags.add(hashtagArray.get(i).getAsString());
         }
+        cd.setHashtags(hashtags);
       }
-
-      // Mergeable.
-      JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-      if (mergeableElement != null) {
-        String mergeable = mergeableElement.getAsString();
-        if ("1".equals(mergeable)) {
-          cd.setMergeable(true);
-        } else if ("0".equals(mergeable)) {
-          cd.setMergeable(false);
-        }
-      }
-
-      // Reviewed-by.
-      if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
-        JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
-        if (reviewedBy.size() > 0) {
-          Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-          for (int i = 0; i < reviewedBy.size(); i++) {
-            int aId = reviewedBy.get(i).getAsInt();
-            if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
-              break;
-            }
-            accounts.add(new Account.Id(aId));
-          }
-          cd.setReviewedBy(accounts);
-        }
-      } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
-        cd.setReviewedBy(Collections.emptySet());
-      }
-
-      // Hashtag.
-      if (source.get(ChangeField.HASHTAG.getName()) != null) {
-        JsonArray hashtagArray = source.get(ChangeField.HASHTAG.getName()).getAsJsonArray();
-        if (hashtagArray.size() > 0) {
-          Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtagArray.size());
-          for (int i = 0; i < hashtagArray.size(); i++) {
-            hashtags.add(hashtagArray.get(i).getAsString());
-          }
-          cd.setHashtags(hashtags);
-        }
-      } else if (fields.contains(ChangeField.HASHTAG.getName())) {
-        cd.setHashtags(Collections.emptySet());
-      }
-
-      // Star.
-      if (source.get(ChangeField.STAR.getName()) != null) {
-        JsonArray starArray = source.get(ChangeField.STAR.getName()).getAsJsonArray();
-        if (starArray.size() > 0) {
-          ListMultimap<Account.Id, String> stars =
-              MultimapBuilder.hashKeys().arrayListValues().build();
-          for (int i = 0; i < starArray.size(); i++) {
-            StarredChangesUtil.StarField starField =
-                StarredChangesUtil.StarField.parse(starArray.get(i).getAsString());
-            stars.put(starField.accountId(), starField.label());
-          }
-          cd.setStars(stars);
-        }
-      } else if (fields.contains(ChangeField.STAR.getName())) {
-        cd.setStars(ImmutableListMultimap.of());
-      }
-
-      // Reviewer.
-      if (source.get(ChangeField.REVIEWER.getName()) != null) {
-        cd.setReviewers(
-            ChangeField.parseReviewerFieldValues(
-                FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.REVIEWER.getName())) {
-        cd.setReviewers(ReviewerSet.empty());
-      }
-
-      // Reviewer-by-email.
-      if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
-        cd.setReviewersByEmail(
-            ChangeField.parseReviewerByEmailFieldValues(
-                FluentIterable.from(
-                        source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
-        cd.setReviewersByEmail(ReviewerByEmailSet.empty());
-      }
-
-      // Pending-reviewer.
-      if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
-        cd.setPendingReviewers(
-            ChangeField.parseReviewerFieldValues(
-                FluentIterable.from(
-                        source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
-        cd.setPendingReviewers(ReviewerSet.empty());
-      }
-
-      // Pending-reviewer-by-email.
-      if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
-        cd.setPendingReviewersByEmail(
-            ChangeField.parseReviewerByEmailFieldValues(
-                FluentIterable.from(
-                        source
-                            .get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())
-                            .getAsJsonArray())
-                    .transform(JsonElement::getAsString)));
-      } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
-        cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
-      }
-
-      // Stored-submit-record-strict.
-      decodeSubmitRecords(
-          source,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
-          ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
-          cd);
-
-      // Stored-submit-record-leniant.
-      decodeSubmitRecords(
-          source,
-          ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
-          ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
-          cd);
-
-      // Ref-state.
-      if (fields.contains(ChangeField.REF_STATE.getName())) {
-        cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
-      }
-
-      // Ref-state-pattern.
-      if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
-        cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
-      }
-
-      // Unresolved-comment-count.
-      decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
-
-      return cd;
+    } else if (fields.contains(ChangeField.HASHTAG.getName())) {
+      cd.setHashtags(Collections.emptySet());
     }
 
-    private Iterable<byte[]> getByteArray(JsonObject source, String name) {
-      JsonElement element = source.get(name);
-      return element != null
-          ? Iterables.transform(element.getAsJsonArray(), e -> Base64.decodeBase64(e.getAsString()))
-          : Collections.emptyList();
+    // Star.
+    if (source.get(ChangeField.STAR.getName()) != null) {
+      JsonArray starArray = source.get(ChangeField.STAR.getName()).getAsJsonArray();
+      if (starArray.size() > 0) {
+        ListMultimap<Account.Id, String> stars =
+            MultimapBuilder.hashKeys().arrayListValues().build();
+        for (int i = 0; i < starArray.size(); i++) {
+          StarredChangesUtil.StarField starField =
+              StarredChangesUtil.StarField.parse(starArray.get(i).getAsString());
+          stars.put(starField.accountId(), starField.label());
+        }
+        cd.setStars(stars);
+      }
+    } else if (fields.contains(ChangeField.STAR.getName())) {
+      cd.setStars(ImmutableListMultimap.of());
     }
 
-    private void decodeSubmitRecords(
-        JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
-      JsonArray records = doc.getAsJsonArray(fieldName);
-      if (records == null) {
-        return;
-      }
-      ChangeField.parseSubmitRecords(
-          FluentIterable.from(records)
-              .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
-              .toList(),
-          opts,
-          out);
+    // Reviewer.
+    if (source.get(ChangeField.REVIEWER.getName()) != null) {
+      cd.setReviewers(
+          ChangeField.parseReviewerFieldValues(
+              FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.REVIEWER.getName())) {
+      cd.setReviewers(ReviewerSet.empty());
     }
 
-    private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
-      JsonElement count = doc.get(fieldName);
-      if (count == null) {
-        return;
-      }
-      out.setUnresolvedCommentCount(count.getAsInt());
+    // Reviewer-by-email.
+    if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
+      cd.setReviewersByEmail(
+          ChangeField.parseReviewerByEmailFieldValues(
+              FluentIterable.from(
+                      source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
+      cd.setReviewersByEmail(ReviewerByEmailSet.empty());
     }
+
+    // Pending-reviewer.
+    if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
+      cd.setPendingReviewers(
+          ChangeField.parseReviewerFieldValues(
+              FluentIterable.from(
+                      source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
+      cd.setPendingReviewers(ReviewerSet.empty());
+    }
+
+    // Pending-reviewer-by-email.
+    if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
+      cd.setPendingReviewersByEmail(
+          ChangeField.parseReviewerByEmailFieldValues(
+              FluentIterable.from(
+                      source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
+                  .transform(JsonElement::getAsString)));
+    } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
+      cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
+    }
+
+    // Stored-submit-record-strict.
+    decodeSubmitRecords(
+        source,
+        ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
+        ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
+        cd);
+
+    // Stored-submit-record-leniant.
+    decodeSubmitRecords(
+        source,
+        ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
+        ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
+        cd);
+
+    // Ref-state.
+    if (fields.contains(ChangeField.REF_STATE.getName())) {
+      cd.setRefStates(getByteArray(source, ChangeField.REF_STATE.getName()));
+    }
+
+    // Ref-state-pattern.
+    if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
+      cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
+    }
+
+    // Unresolved-comment-count.
+    decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
+
+    return cd;
+  }
+
+  private Iterable<byte[]> getByteArray(JsonObject source, String name) {
+    JsonElement element = source.get(name);
+    return element != null
+        ? Iterables.transform(element.getAsJsonArray(), e -> Base64.decodeBase64(e.getAsString()))
+        : Collections.emptyList();
+  }
+
+  private void decodeSubmitRecords(
+      JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
+    JsonArray records = doc.getAsJsonArray(fieldName);
+    if (records == null) {
+      return;
+    }
+    ChangeField.parseSubmitRecords(
+        FluentIterable.from(records)
+            .transform(i -> new String(decodeBase64(i.toString()), UTF_8))
+            .toList(),
+        opts,
+        out);
+  }
+
+  private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
+    JsonElement count = doc.get(fieldName);
+    if (count == null) {
+      return;
+    }
+    out.setUnresolvedCommentCount(count.getAsInt());
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
index 6ca4ad5..7f0ec34 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -31,31 +29,18 @@
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
-import io.searchbox.core.Search;
 import io.searchbox.core.search.sort.Sort;
-import io.searchbox.core.search.sort.Sort.Sorting;
 import java.io.IOException;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.index.query.QueryBuilder;
-import org.elasticsearch.search.builder.SearchSourceBuilder;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
     implements GroupIndex {
@@ -70,8 +55,6 @@
   static final String GROUPS = "groups";
   static final String GROUPS_PREFIX = GROUPS + "_";
 
-  private static final Logger log = LoggerFactory.getLogger(ElasticGroupIndex.class);
-
   private final GroupMapping mapping;
   private final Provider<GroupCache> groupCache;
 
@@ -108,7 +91,9 @@
   @Override
   public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
       throws QueryParseException {
-    return new QuerySource(p, opts);
+    Sort sort = new Sort(GroupField.UUID.getName(), Sort.Sorting.ASC);
+    sort.setIgnoreUnmapped();
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::groupFields), GROUPS, sort);
   }
 
   @Override
@@ -127,93 +112,18 @@
     return group.getGroupUUID().get();
   }
 
-  private class QuerySource implements DataSource<InternalGroup> {
-    private final Search search;
-    private final Set<String> fields;
-
-    QuerySource(Predicate<InternalGroup> p, QueryOptions opts) throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.groupFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      Sort sort = new Sort(GroupField.UUID.getName(), Sorting.ASC);
-      sort.setIgnoreUnmapped();
-
-      search =
-          new Search.Builder(searchSource.toString())
-              .addType(GROUPS)
-              .addIndex(indexName)
-              .addSort(ImmutableList.of(sort))
-              .build();
+  @Override
+  protected InternalGroup fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.get("_source");
+    if (source == null) {
+      source = json.getAsJsonObject().get("fields");
     }
 
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<InternalGroup> read() throws OrmException {
-      try {
-        List<InternalGroup> results = Collections.emptyList();
-        JestResult result = client.execute(search);
-        if (result.isSucceeded()) {
-          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              Optional<InternalGroup> internalGroup = toInternalGroup(json.get(i));
-              internalGroup.ifPresent(results::add);
-            }
-          }
-        } else {
-          log.error(result.getErrorMessage());
-        }
-        final List<InternalGroup> r = Collections.unmodifiableList(results);
-        return new ResultSet<InternalGroup>() {
-          @Override
-          public Iterator<InternalGroup> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<InternalGroup> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public String toString() {
-      return search.toString();
-    }
-
-    private Optional<InternalGroup> toInternalGroup(JsonElement json) {
-      JsonElement source = json.getAsJsonObject().get("_source");
-      if (source == null) {
-        source = json.getAsJsonObject().get("fields");
-      }
-
-      AccountGroup.UUID uuid =
-          new AccountGroup.UUID(
-              source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
-      // Use the GroupCache rather than depending on any stored fields in the
-      // document (of which there shouldn't be any).
-      return groupCache.get().get(uuid);
-    }
+    AccountGroup.UUID uuid =
+        new AccountGroup.UUID(
+            source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
+    // Use the GroupCache rather than depending on any stored fields in the
+    // document (of which there shouldn't be any).
+    return groupCache.get().get(uuid).orElse(null);
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
index 780f023..a564e5b 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.elasticsearch;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.Lists;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
@@ -31,30 +29,19 @@
 import com.google.gerrit.server.index.project.ProjectIndex;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectData;
-import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import io.searchbox.client.JestResult;
 import io.searchbox.core.Bulk;
 import io.searchbox.core.Bulk.Builder;
-import io.searchbox.core.Search;
 import io.searchbox.core.search.sort.Sort;
 import io.searchbox.core.search.sort.Sort.Sorting;
 import java.io.IOException;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.index.query.QueryBuilder;
-import org.elasticsearch.search.builder.SearchSourceBuilder;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class ElasticProjectIndex extends AbstractElasticIndex<Project.NameKey, ProjectData>
     implements ProjectIndex {
@@ -69,8 +56,6 @@
   static final String PROJECTS = "projects";
   static final String PROJECTS_PREFIX = PROJECTS + "_";
 
-  private static final Logger log = LoggerFactory.getLogger(ElasticProjectIndex.class);
-
   private final ProjectMapping mapping;
   private final Provider<ProjectCache> projectCache;
 
@@ -107,7 +92,9 @@
   @Override
   public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
       throws QueryParseException {
-    return new QuerySource(p, opts);
+    Sort sort = new Sort(ProjectField.NAME.getName(), Sorting.ASC);
+    sort.setIgnoreUnmapped();
+    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::projectFields), PROJECTS, sort);
   }
 
   @Override
@@ -126,90 +113,16 @@
     return projectState.getProject().getName();
   }
 
-  private class QuerySource implements DataSource<ProjectData> {
-    private final Search search;
-    private final Set<String> fields;
-
-    QuerySource(Predicate<ProjectData> p, QueryOptions opts) throws QueryParseException {
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      fields = IndexUtils.projectFields(opts);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder()
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(fields));
-
-      Sort sort = new Sort(ProjectField.NAME.getName(), Sorting.ASC);
-      sort.setIgnoreUnmapped();
-
-      search =
-          new Search.Builder(searchSource.toString())
-              .addType(PROJECTS)
-              .addIndex(indexName)
-              .addSort(ImmutableList.of(sort))
-              .build();
+  @Override
+  protected ProjectData fromDocument(JsonObject json, Set<String> fields) {
+    JsonElement source = json.get("_source");
+    if (source == null) {
+      source = json.getAsJsonObject().get("fields");
     }
 
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<ProjectData> read() throws OrmException {
-      try {
-        List<ProjectData> results = Collections.emptyList();
-        JestResult result = client.execute(search);
-        if (result.isSucceeded()) {
-          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            results = Lists.newArrayListWithCapacity(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              results.add(toProjectData(json.get(i)));
-            }
-          }
-        } else {
-          log.error(result.getErrorMessage());
-        }
-        final List<ProjectData> r = Collections.unmodifiableList(results);
-        return new ResultSet<ProjectData>() {
-          @Override
-          public Iterator<ProjectData> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<ProjectData> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      }
-    }
-
-    @Override
-    public String toString() {
-      return search.toString();
-    }
-
-    private ProjectData toProjectData(JsonElement json) {
-      JsonElement source = json.getAsJsonObject().get("_source");
-      if (source == null) {
-        source = json.getAsJsonObject().get("fields");
-      }
-
-      Project.NameKey nameKey =
-          new Project.NameKey(
-              source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
-      return projectCache.get().get(nameKey).toProjectData();
-    }
+    Project.NameKey nameKey =
+        new Project.NameKey(
+            source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
+    return projectCache.get().get(nameKey).toProjectData();
   }
 }
diff --git a/java/com/google/gerrit/extensions/api/projects/Projects.java b/java/com/google/gerrit/extensions/api/projects/Projects.java
index 02cce3a..b33f61e 100644
--- a/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ b/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -92,6 +92,7 @@
     private int limit;
     private int start;
     private boolean showTree;
+    private boolean all;
     private FilterType type = FilterType.ALL;
 
     public List<ProjectInfo> get() throws RestApiException {
@@ -152,6 +153,11 @@
       return this;
     }
 
+    public ListRequest withAll() {
+      this.all = true;
+      return this;
+    }
+
     public boolean getDescription() {
       return description;
     }
@@ -187,6 +193,10 @@
     public FilterType getFilterType() {
       return type;
     }
+
+    public boolean isAll() {
+      return all;
+    }
   }
 
   /**
diff --git a/java/com/google/gerrit/extensions/common/testing/BUILD b/java/com/google/gerrit/extensions/common/testing/BUILD
index 160c14a..54d44c6 100644
--- a/java/com/google/gerrit/extensions/common/testing/BUILD
+++ b/java/com/google/gerrit/extensions/common/testing/BUILD
@@ -8,5 +8,6 @@
         "//java/com/google/gerrit/extensions/client/testing:client-test-util",
         "//java/com/google/gerrit/truth",
         "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit:jgit",
     ],
 )
diff --git a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
index 7495a52..cdbef34 100644
--- a/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/GitPersonSubject.java
@@ -24,6 +24,8 @@
 import com.google.common.truth.Truth;
 import com.google.gerrit.extensions.common.GitPerson;
 import java.sql.Timestamp;
+import java.util.Date;
+import org.eclipse.jgit.lib.PersonIdent;
 
 public class GitPersonSubject extends Subject<GitPersonSubject, GitPerson> {
 
@@ -65,4 +67,14 @@
     date().isEqualTo(other.date);
     tz().isEqualTo(other.tz);
   }
+
+  public void matches(PersonIdent ident) {
+    isNotNull();
+    name().isEqualTo(ident.getName());
+    email().isEqualTo(ident.getEmailAddress());
+    Truth.assertThat(new Date(actual().date.getTime()))
+        .named("rounded date")
+        .isEqualTo(ident.getWhen());
+    tz().isEqualTo(ident.getTimeZoneOffset());
+  }
 }
diff --git a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index 0df0aa9..967259a 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.api.accounts.GpgApiAdapter;
+import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/java/com/google/gerrit/gpg/api/GpgApiModule.java b/java/com/google/gerrit/gpg/api/GpgApiModule.java
index f7102d8..f0d34f3 100644
--- a/java/com/google/gerrit/gpg/api/GpgApiModule.java
+++ b/java/com/google/gerrit/gpg/api/GpgApiModule.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.gpg.server.PostGpgKeys;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.api.accounts.GpgApiAdapter;
+import com.google.gerrit.server.account.GpgApiAdapter;
 import java.util.List;
 import java.util.Map;
 
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index 8e1f898..7fc3074 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -17,6 +17,7 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
+        "//java/com/google/gerrit/server/api",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/sshd",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 44203dc..b5995a8 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.server.StartupChecks;
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
@@ -70,6 +71,7 @@
 import com.google.gerrit.server.mail.receive.MailReceiver;
 import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
@@ -306,6 +308,7 @@
     }
     modules.add(new DatabaseModule());
     modules.add(new NotesMigration.Module());
+    modules.add(new GroupsMigration.Module());
     modules.add(new DropWizardMetricMaker.ApiModule());
     return Guice.createInjector(PRODUCTION, modules);
   }
@@ -330,6 +333,7 @@
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
+    modules.add(new GerritApiModule());
     modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index 34f7d33..2d7e31e 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index;
 
 import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
@@ -102,14 +103,35 @@
     } catch (OrmException e) {
       throw new IOException(e);
     }
-    switch (results.size()) {
-      case 0:
-        return Optional.empty();
-      case 1:
-        return Optional.of(results.get(0));
-      default:
-        throw new IOException("Multiple results found in index for key " + key + ": " + results);
+    if (results.size() > 1) {
+      throw new IOException("Multiple results found in index for key " + key + ": " + results);
     }
+    return results.stream().findFirst();
+  }
+
+  /**
+   * Get a single raw document from the index.
+   *
+   * @param key document key.
+   * @param opts query options. Options that do not make sense in the context of a single document,
+   *     such as start, will be ignored.
+   * @return an abstraction of a raw index document to retrieve fields from.
+   * @throws IOException
+   */
+  default Optional<FieldBundle> getRaw(K key, QueryOptions opts) throws IOException {
+    opts = opts.withStart(0).withLimit(2);
+    List<FieldBundle> results;
+    try {
+      results = getSource(keyPredicate(key), opts).readRaw().toList();
+    } catch (QueryParseException e) {
+      throw new IOException("Unexpected QueryParseException during get()", e);
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+    if (results.size() > 1) {
+      throw new IOException("Multiple results found in index for key " + key + ": " + results);
+    }
+    return results.stream().findFirst();
   }
 
   /**
diff --git a/java/com/google/gerrit/index/IndexedQuery.java b/java/com/google/gerrit/index/IndexedQuery.java
index 050b4a9..143cc26 100644
--- a/java/com/google/gerrit/index/IndexedQuery.java
+++ b/java/com/google/gerrit/index/IndexedQuery.java
@@ -17,6 +17,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.IndexPredicate;
 import com.google.gerrit.index.query.Paginated;
 import com.google.gerrit.index.query.Predicate;
@@ -86,6 +87,11 @@
   }
 
   @Override
+  public ResultSet<FieldBundle> readRaw() throws OrmException {
+    return source.readRaw();
+  }
+
+  @Override
   public ResultSet<T> restart(int start) throws OrmException {
     opts = opts.withStart(start);
     try {
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index b57fb5f..0401dab 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Ints;
 import java.util.Set;
+import java.util.function.Function;
 
 @AutoValue
 public abstract class QueryOptions {
@@ -53,4 +54,8 @@
   public QueryOptions withStart(int newStart) {
     return create(config(), newStart, limit(), fields());
   }
+
+  public QueryOptions filterFields(Function<QueryOptions, Set<String>> filter) {
+    return create(config(), start(), limit(), filter.apply(this));
+  }
 }
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index 16620b3..e2605f4 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -89,6 +89,12 @@
     }
   }
 
+  @Override
+  public ResultSet<FieldBundle> readRaw() throws OrmException {
+    // TOOD(hiesel): Implement
+    throw new UnsupportedOperationException("not implemented");
+  }
+
   private ResultSet<T> readImpl() throws OrmException {
     if (source == null) {
       throw new OrmException("No DataSource: " + this);
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index 77dcca2..88cc0e3c 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -23,4 +23,7 @@
 
   /** @return read from the database and return the results. */
   ResultSet<T> read() throws OrmException;
+
+  /** @return read from the database and return the raw results. */
+  ResultSet<FieldBundle> readRaw() throws OrmException;
 }
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
new file mode 100644
index 0000000..6ecb6e6
--- /dev/null
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -0,0 +1,61 @@
+// 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.index.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.gerrit.index.FieldDef;
+
+/** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
+public class FieldBundle {
+
+  // Map String => List{Integer, Long, Timestamp, String, byte[]}
+  private ImmutableListMultimap<String, Object> fields;
+
+  public FieldBundle(ListMultimap<String, Object> fields) {
+    this.fields = ImmutableListMultimap.copyOf(fields);
+  }
+
+  /**
+   * Get a field's value based on the field definition.
+   *
+   * @param fieldDef the definition of the field of which the value should be retrieved. The field
+   *     must be stored and contained in the result set as specified by {@link
+   *     com.google.gerrit.index.QueryOptions}.
+   * @param <T> Data type of the returned object based on the field definition
+   * @return Either a single element or an Iterable based on the field definition. An empty list is
+   *     returned for repeated fields that are not contained in the result.
+   * @throws IllegalArgumentException if the requested field is not stored or not present. This
+   *     check is only enforced on non-repeatable fields.
+   */
+  @SuppressWarnings("unchecked")
+  public <T> T getValue(FieldDef<?, T> fieldDef) {
+    checkArgument(fieldDef.isStored(), "Field must be stored");
+    checkArgument(
+        fields.containsKey(fieldDef.getName()) || fieldDef.isRepeatable(),
+        "Field %s is not in result set %s",
+        fieldDef.getName(),
+        fields.keySet());
+
+    Iterable<Object> result = fields.get(fieldDef.getName());
+    if (fieldDef.isRepeatable()) {
+      return (T) result;
+    }
+    return (T) Iterables.getOnlyElement(result);
+  }
+}
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 9d474dd..0c261f6 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.lucene;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.AbstractFuture;
 import com.google.common.util.concurrent.Futures;
@@ -28,12 +31,22 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
 import com.google.gerrit.index.Index;
+import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -43,6 +56,7 @@
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.Field.Store;
@@ -52,13 +66,18 @@
 import org.apache.lucene.document.StringField;
 import org.apache.lucene.document.TextField;
 import org.apache.lucene.index.IndexWriter;
+import org.apache.lucene.index.IndexableField;
 import org.apache.lucene.index.Term;
 import org.apache.lucene.index.TrackingIndexWriter;
 import org.apache.lucene.search.ControlledRealTimeReopenThread;
 import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ReferenceManager;
 import org.apache.lucene.search.ReferenceManager.RefreshListener;
+import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.TopFieldDocs;
 import org.apache.lucene.store.AlreadyClosedException;
 import org.apache.lucene.store.Directory;
 import org.slf4j.Logger;
@@ -294,6 +313,8 @@
     return result;
   }
 
+  protected abstract V fromDocument(Document doc);
+
   void add(Document doc, Values<V> values) {
     String name = values.getField().getName();
     FieldType<?> type = values.getField().getType();
@@ -328,6 +349,29 @@
     }
   }
 
+  protected FieldBundle toFieldBundle(Document doc) {
+    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
+    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
+    for (IndexableField field : doc.getFields()) {
+      checkArgument(allFields.containsKey(field.name()), "Unrecognized field " + field.name());
+      FieldType<?> type = allFields.get(field.name()).getType();
+      if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
+        rawFields.put(field.name(), field.stringValue());
+      } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
+        rawFields.put(field.name(), field.numericValue().intValue());
+      } else if (type == FieldType.LONG) {
+        rawFields.put(field.name(), field.numericValue().longValue());
+      } else if (type == FieldType.TIMESTAMP) {
+        rawFields.put(field.name(), new Timestamp(field.numericValue().longValue()));
+      } else if (type == FieldType.STORED_ONLY) {
+        rawFields.put(field.name(), field.binaryValue().bytes);
+      } else {
+        throw FieldType.badFieldType(type);
+      }
+    }
+    return new FieldBundle(rawFields);
+  }
+
   private static Field.Store store(FieldDef<?, ?> f) {
     return f.isStored() ? Field.Store.YES : Field.Store.NO;
   }
@@ -416,4 +460,76 @@
   public Schema<V> getSchema() {
     return schema;
   }
+
+  protected class LuceneQuerySource implements DataSource<V> {
+    private final QueryOptions opts;
+    private final Query query;
+    private final Sort sort;
+
+    LuceneQuerySource(QueryOptions opts, Query query, Sort sort) {
+      this.opts = opts;
+      this.query = query;
+      this.sort = sort;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<V> read() throws OrmException {
+      return readImpl((doc) -> fromDocument(doc));
+    }
+
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      return readImpl(AbstractLuceneIndex.this::toFieldBundle);
+    }
+
+    private <T> ResultSet<T> readImpl(Function<Document, T> mapper) throws OrmException {
+      IndexSearcher searcher = null;
+      try {
+        searcher = acquire();
+        int realLimit = opts.start() + opts.limit();
+        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        List<T> result = new ArrayList<>(docs.scoreDocs.length);
+        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
+          ScoreDoc sd = docs.scoreDocs[i];
+          Document doc = searcher.doc(sd.doc, opts.fields());
+          T mapperResult = mapper.apply(doc);
+          if (mapperResult != null) {
+            result.add(mapperResult);
+          }
+        }
+        final List<T> r = Collections.unmodifiableList(result);
+        return new ResultSet<T>() {
+          @Override
+          public Iterator<T> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<T> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      } finally {
+        if (searcher != null) {
+          try {
+            release(searcher);
+          } catch (IOException e) {
+            log.warn("cannot release Lucene searcher", e);
+          }
+        }
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 126c79f..7d7cbef 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.Schema.Values;
 import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -85,6 +86,12 @@
     throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
   }
 
+  // Make method public so that it can be used in LuceneChangeIndex
+  @Override
+  public FieldBundle toFieldBundle(Document doc) {
+    return super.toFieldBundle(doc);
+  }
+
   @Override
   void add(Document doc, Values<ChangeData> values) {
     // Add separate DocValues fields for those fields needed for sorting.
@@ -98,4 +105,9 @@
     }
     super.add(doc, values);
   }
+
+  @Override
+  protected ChangeData fromDocument(Document doc) {
+    throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
+  }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 7a4cd40..8ceea0d 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -28,38 +28,24 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
 import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.Term;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.Query;
-import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
-import org.apache.lucene.search.TopFieldDocs;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.store.RAMDirectory;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class LuceneAccountIndex extends AbstractLuceneIndex<Account.Id, AccountState>
     implements AccountIndex {
-  private static final Logger log = LoggerFactory.getLogger(LuceneAccountIndex.class);
-
   private static final String ACCOUNTS = "accounts";
 
   private static final String ID_SORT_FIELD = sortFieldName(ID);
@@ -127,76 +113,14 @@
   @Override
   public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
       throws QueryParseException {
-    return new QuerySource(
-        opts,
+    return new LuceneQuerySource(
+        opts.filterFields(IndexUtils::accountFields),
         queryBuilder.toQuery(p),
         new Sort(new SortField(ID_SORT_FIELD, SortField.Type.LONG, true)));
   }
 
-  private class QuerySource implements DataSource<AccountState> {
-    private final QueryOptions opts;
-    private final Query query;
-    private final Sort sort;
-
-    private QuerySource(QueryOptions opts, Query query, Sort sort) {
-      this.opts = opts;
-      this.query = query;
-      this.sort = sort;
-    }
-
-    @Override
-    public int getCardinality() {
-      // TODO(dborowitz): In contrast to the comment in
-      // LuceneChangeIndex.QuerySource#getCardinality, at this point I actually
-      // think we might just want to remove getCardinality.
-      return 10;
-    }
-
-    @Override
-    public ResultSet<AccountState> read() throws OrmException {
-      IndexSearcher searcher = null;
-      try {
-        searcher = acquire();
-        int realLimit = opts.start() + opts.limit();
-        TopFieldDocs docs = searcher.search(query, realLimit, sort);
-        List<AccountState> result = new ArrayList<>(docs.scoreDocs.length);
-        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
-          ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, IndexUtils.accountFields(opts));
-          result.add(toAccountState(doc));
-        }
-        final List<AccountState> r = Collections.unmodifiableList(result);
-        return new ResultSet<AccountState>() {
-          @Override
-          public Iterator<AccountState> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<AccountState> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      } finally {
-        if (searcher != null) {
-          try {
-            release(searcher);
-          } catch (IOException e) {
-            log.warn("cannot release Lucene searcher", e);
-          }
-        }
-      }
-    }
-  }
-
-  private AccountState toAccountState(Document doc) {
+  @Override
+  protected AccountState fromDocument(Document doc) {
     Account.Id id = new Account.Id(doc.getField(ID.getName()).numericValue().intValue());
     // Use the AccountCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any). The most expensive part to
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index b004333..b30e66c 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -26,6 +26,7 @@
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Function;
 import com.google.common.base.Throwables;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
@@ -37,6 +38,7 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -243,7 +245,7 @@
     if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
       indexes.add(closedIndex);
     }
-    return new QuerySource(indexes, p, opts, getSort());
+    return new QuerySource(indexes, p, opts, getSort(), openIndex::toFieldBundle);
   }
 
   @Override
@@ -269,15 +271,21 @@
     private final Query query;
     private final QueryOptions opts;
     private final Sort sort;
+    private final Function<Document, FieldBundle> rawDocumentMapper;
 
     private QuerySource(
-        List<ChangeSubIndex> indexes, Predicate<ChangeData> predicate, QueryOptions opts, Sort sort)
+        List<ChangeSubIndex> indexes,
+        Predicate<ChangeData> predicate,
+        QueryOptions opts,
+        Sort sort,
+        Function<Document, FieldBundle> rawDocumentMapper)
         throws QueryParseException {
       this.indexes = indexes;
       this.predicate = predicate;
       this.query = checkNotNull(queryBuilder.toQuery(predicate), "null query from Lucene");
       this.opts = opts;
       this.sort = sort;
+      this.rawDocumentMapper = rawDocumentMapper;
     }
 
     @Override
@@ -319,6 +327,33 @@
           fields);
     }
 
+    @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      List<Document> documents;
+      try {
+        documents = doRead(IndexUtils.changeFields(opts));
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+      List<FieldBundle> fieldBundles = documents.stream().map(rawDocumentMapper).collect(toList());
+      return new ResultSet<FieldBundle>() {
+        @Override
+        public Iterator<FieldBundle> iterator() {
+          return fieldBundles.iterator();
+        }
+
+        @Override
+        public List<FieldBundle> toList() {
+          return fieldBundles;
+        }
+
+        @Override
+        public void close() {
+          // Do nothing.
+        }
+      };
+    }
+
     private List<Document> doRead(Set<String> fields) throws IOException {
       IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
       try {
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index 32870cb..7878afe 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -28,38 +28,24 @@
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.Term;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.Query;
-import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
-import org.apache.lucene.search.TopFieldDocs;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.store.RAMDirectory;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, InternalGroup>
     implements GroupIndex {
-  private static final Logger log = LoggerFactory.getLogger(LuceneGroupIndex.class);
 
   private static final String GROUPS = "groups";
 
@@ -128,77 +114,17 @@
   @Override
   public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
       throws QueryParseException {
-    return new QuerySource(
-        opts,
+    return new LuceneQuerySource(
+        opts.filterFields(IndexUtils::groupFields),
         queryBuilder.toQuery(p),
         new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
-  private class QuerySource implements DataSource<InternalGroup> {
-    private final QueryOptions opts;
-    private final Query query;
-    private final Sort sort;
-
-    private QuerySource(QueryOptions opts, Query query, Sort sort) {
-      this.opts = opts;
-      this.query = query;
-      this.sort = sort;
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<InternalGroup> read() throws OrmException {
-      IndexSearcher searcher = null;
-      try {
-        searcher = acquire();
-        int realLimit = opts.start() + opts.limit();
-        TopFieldDocs docs = searcher.search(query, realLimit, sort);
-        List<InternalGroup> result = new ArrayList<>(docs.scoreDocs.length);
-        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
-          ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, IndexUtils.groupFields(opts));
-          Optional<InternalGroup> internalGroup = toInternalGroup(doc);
-          internalGroup.ifPresent(result::add);
-        }
-        final List<InternalGroup> r = Collections.unmodifiableList(result);
-        return new ResultSet<InternalGroup>() {
-          @Override
-          public Iterator<InternalGroup> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<InternalGroup> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      } finally {
-        if (searcher != null) {
-          try {
-            release(searcher);
-          } catch (IOException e) {
-            log.warn("cannot release Lucene searcher", e);
-          }
-        }
-      }
-    }
-  }
-
-  private Optional<InternalGroup> toInternalGroup(Document doc) {
+  @Override
+  protected InternalGroup fromDocument(Document doc) {
     AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
-    return groupCache.get().get(uuid);
+    return groupCache.get().get(uuid).orElse(null);
   }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 6354f61..e776a8b 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -28,38 +28,24 @@
 import com.google.gerrit.server.index.project.ProjectIndex;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectData;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
 import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.Term;
-import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.Query;
-import org.apache.lucene.search.ScoreDoc;
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
-import org.apache.lucene.search.TopFieldDocs;
 import org.apache.lucene.store.Directory;
 import org.apache.lucene.store.FSDirectory;
 import org.apache.lucene.store.RAMDirectory;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, ProjectData>
     implements ProjectIndex {
-  private static final Logger log = LoggerFactory.getLogger(LuceneProjectIndex.class);
-
   private static final String PROJECTS = "projects";
 
   private static final String NAME_SORT_FIELD = sortFieldName(NAME);
@@ -127,73 +113,14 @@
   @Override
   public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
       throws QueryParseException {
-    return new QuerySource(
-        opts,
+    return new LuceneQuerySource(
+        opts.filterFields(IndexUtils::projectFields),
         queryBuilder.toQuery(p),
         new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
-  private class QuerySource implements DataSource<ProjectData> {
-    private final QueryOptions opts;
-    private final Query query;
-    private final Sort sort;
-
-    private QuerySource(QueryOptions opts, Query query, Sort sort) {
-      this.opts = opts;
-      this.query = query;
-      this.sort = sort;
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<ProjectData> read() throws OrmException {
-      IndexSearcher searcher = null;
-      try {
-        searcher = acquire();
-        int realLimit = opts.start() + opts.limit();
-        TopFieldDocs docs = searcher.search(query, realLimit, sort);
-        List<ProjectData> result = new ArrayList<>(docs.scoreDocs.length);
-        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
-          ScoreDoc sd = docs.scoreDocs[i];
-          Document doc = searcher.doc(sd.doc, IndexUtils.projectFields(opts));
-          result.add(toProjectData(doc));
-        }
-        final List<ProjectData> r = Collections.unmodifiableList(result);
-        return new ResultSet<ProjectData>() {
-          @Override
-          public Iterator<ProjectData> iterator() {
-            return r.iterator();
-          }
-
-          @Override
-          public List<ProjectData> toList() {
-            return r;
-          }
-
-          @Override
-          public void close() {
-            // Do nothing.
-          }
-        };
-      } catch (IOException e) {
-        throw new OrmException(e);
-      } finally {
-        if (searcher != null) {
-          try {
-            release(searcher);
-          } catch (IOException e) {
-            log.warn("cannot release Lucene searcher", e);
-          }
-        }
-      }
-    }
-  }
-
-  private ProjectData toProjectData(Document doc) {
+  @Override
+  protected ProjectData fromDocument(Document doc) {
     Project.NameKey nameKey = new Project.NameKey(doc.getField(NAME.getName()).stringValue());
     return projectCache.get().get(nameKey).toProjectData();
   }
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index dce3ea1..4dfaf1c 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -35,6 +35,7 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
+        "//java/com/google/gerrit/server/api",
         "//java/com/google/gerrit/server/cache/h2",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/sshd",
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index a369953..d72b46f 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.StartupChecks;
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.change.ChangeCleanupRunner;
 import com.google.gerrit.server.config.AuthConfig;
@@ -411,6 +412,8 @@
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
+    modules.add(new GerritApiModule());
+
     modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
     modules.add(new DefaultPermissionBackendModule());
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 80bdb4b..3385244 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -20,7 +20,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
@@ -37,7 +39,10 @@
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,6 +51,7 @@
 import java.nio.file.Path;
 import java.sql.Timestamp;
 import java.util.List;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -67,43 +73,91 @@
   private final InitFlags flags;
   private final SitePaths site;
   private final String allUsers;
-  private final boolean writeGroupsToNoteDb;
+  private final GroupsMigration groupsMigration;
 
   @Inject
   public GroupsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
     this.flags = flags;
     this.site = site;
     this.allUsers = allUsers.get();
-    // TODO(aliceks): Remove this flag when all other necessary TODOs for writing groups to NoteDb
-    // have been addressed.
-    // Don't flip this flag in a production setting! We only added it to spread the implementation
-    // of groups in NoteDb among several changes which are gradually merged.
-    writeGroupsToNoteDb = flags.cfg.getBoolean("user", null, "writeGroupsToNoteDb", false);
+    this.groupsMigration = new GroupsMigration(flags.cfg);
   }
 
   /**
-   * Returns the {@code AccountGroup} for the specified name.
+   * Returns the {@code AccountGroup} for the specified {@code GroupReference}.
    *
    * @param db the {@code ReviewDb} instance to use for lookups
-   * @param groupName the name of the group
-   * @return the {@code AccountGroup} which has the specified name
+   * @param groupReference the {@code GroupReference} of the group
+   * @return the {@code InternalGroup} represented by the {@code GroupReference}
    * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
    * @throws NoSuchGroupException if a group with such a name doesn't exist
    */
-  public AccountGroup getExistingGroup(ReviewDb db, AccountGroup.NameKey groupName)
-      throws OrmException, NoSuchGroupException {
-    // TODO(aliceks): Add implementation for NoteDb.
-    AccountGroupName accountGroupName = db.accountGroupNames().get(groupName);
+  public InternalGroup getExistingGroup(ReviewDb db, GroupReference groupReference)
+      throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return getExistingGroupFromNoteDb(groupReference);
+    }
+
+    return getExistingGroupFromReviewDb(db, groupReference);
+  }
+
+  private InternalGroup getExistingGroupFromNoteDb(GroupReference groupReference)
+      throws IOException, ConfigInvalidException, NoSuchGroupException {
+    File allUsersRepoPath = getPathToAllUsersRepository();
+    if (allUsersRepoPath != null) {
+      try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
+        AccountGroup.UUID groupUuid = groupReference.getUUID();
+        GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+        return groupConfig
+            .getLoadedGroup()
+            .orElseThrow(() -> new NoSuchGroupException(groupReference.getUUID()));
+      }
+    }
+    throw new NoSuchGroupException(groupReference.getUUID());
+  }
+
+  private static InternalGroup getExistingGroupFromReviewDb(
+      ReviewDb db, GroupReference groupReference) throws OrmException, NoSuchGroupException {
+    String groupName = groupReference.getName();
+    AccountGroupName accountGroupName =
+        db.accountGroupNames().get(new AccountGroup.NameKey(groupName));
     if (accountGroupName == null) {
-      throw new NoSuchGroupException(groupName.toString());
+      throw new NoSuchGroupException(groupName);
     }
 
     AccountGroup.Id groupId = accountGroupName.getId();
     AccountGroup group = db.accountGroups().get(groupId);
     if (group == null) {
-      throw new NoSuchGroupException(groupName.toString());
+      throw new NoSuchGroupException(groupName);
     }
-    return group;
+    return Groups.asInternalGroup(db, group);
+  }
+
+  /**
+   * Returns {@code GroupReference}s for all internal groups.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @return a stream of the {@code GroupReference}s of all internal groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
+   */
+  public Stream<GroupReference> getAllGroupReferences(ReviewDb db)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      File allUsersRepoPath = getPathToAllUsersRepository();
+      if (allUsersRepoPath != null) {
+        try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
+          return GroupNameNotes.loadAllGroupReferences(allUsersRepo).stream();
+        }
+      }
+      return Stream.empty();
+    }
+
+    return Streams.stream(db.accountGroups().all())
+        .map(group -> new GroupReference(group.getGroupUUID(), group.getName()));
   }
 
   /**
@@ -122,7 +176,7 @@
   public void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account account)
       throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
     addGroupMemberInReviewDb(db, groupUuid, account.getId());
-    if (!writeGroupsToNoteDb) {
+    if (!groupsMigration.writeToNoteDb()) {
       return;
     }
     addGroupMemberInNoteDb(groupUuid, account);
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 84698c8..3251c01 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,8 +17,9 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
@@ -26,7 +27,6 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.SequencesOnInit;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
@@ -46,6 +46,7 @@
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Optional;
 import org.apache.commons.validator.routines.EmailValidator;
 
 public class InitAdminUser implements InitStep {
@@ -130,9 +131,18 @@
           a.setPreferredEmail(email);
           accounts.insert(a);
 
-          AccountGroup adminGroup =
-              groupsOnInit.getExistingGroup(db, new AccountGroup.NameKey("Administrators"));
-          groupsOnInit.addGroupMember(db, adminGroup.getGroupUUID(), a);
+          // Only two groups should exist at this point in time and hence iterating over all of them
+          // is cheap.
+          Optional<GroupReference> adminGroupReference =
+              groupsOnInit
+                  .getAllGroupReferences(db)
+                  .filter(group -> group.getName().equals("Administrators"))
+                  .findAny();
+          if (!adminGroupReference.isPresent()) {
+            throw new NoSuchGroupException("Administrators");
+          }
+          GroupReference adminGroup = adminGroupReference.get();
+          groupsOnInit.addGroupMember(db, adminGroup.getUUID(), a);
 
           if (sshKey != null) {
             VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
@@ -146,8 +156,7 @@
             accountIndex.replace(as);
           }
 
-          InternalGroup adminInternalGroup =
-              InternalGroup.create(adminGroup, ImmutableSet.of(id), ImmutableSet.of());
+          InternalGroup adminInternalGroup = groupsOnInit.getExistingGroup(db, adminGroup);
           for (GroupIndex groupIndex : groupIndexCollection.getWriteIndexes()) {
             groupIndex.replace(adminInternalGroup);
           }
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index b59e085..afabcf6 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
@@ -183,6 +184,7 @@
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
     modules.add(new NotesMigration.Module());
+    modules.add(new GroupsMigration.Module());
 
     try {
       return Guice.createInjector(PRODUCTION, modules);
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
index 2dd5b36..c7dc420 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroup.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroup.java
@@ -286,4 +286,25 @@
     return Objects.hash(
         name, groupId, description, visibleToAll, groupUUID, ownerGroupUUID, createdOn);
   }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{"
+        + "name="
+        + name
+        + ", groupId="
+        + groupId
+        + ", description="
+        + description
+        + ", visibleToAll="
+        + visibleToAll
+        + ", groupUUID="
+        + groupUUID
+        + ", ownerGroupUUID="
+        + ownerGroupUUID
+        + ", createdOn="
+        + createdOn
+        + "}";
+  }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
index 30ca38f..17a205e 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
@@ -88,4 +88,9 @@
   public int hashCode() {
     return key.hashCode();
   }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "{key=" + key + "}";
+  }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
index 33955c4..5246d72 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
@@ -142,4 +142,19 @@
   public int hashCode() {
     return Objects.hash(key, addedBy, removedBy, removedOn);
   }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{"
+        + "key="
+        + key
+        + ", addedBy="
+        + addedBy
+        + ", removedBy="
+        + removedBy
+        + ", removedOn="
+        + removedOn
+        + "}";
+  }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
index ea46366..e1e0754 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
@@ -84,4 +84,9 @@
   public int hashCode() {
     return key.hashCode();
   }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + "{key=" + key + "}";
+  }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
index 9968b7d..4ea19d2 100644
--- a/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
+++ b/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
@@ -147,4 +147,19 @@
   public int hashCode() {
     return Objects.hash(key, addedBy, removedBy, removedOn);
   }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName()
+        + "{"
+        + "key="
+        + key
+        + ", addedBy="
+        + addedBy
+        + ", removedBy="
+        + removedBy
+        + ", removedOn="
+        + removedOn
+        + "}";
+  }
 }
diff --git a/java/com/google/gerrit/reviewdb/client/RefNames.java b/java/com/google/gerrit/reviewdb/client/RefNames.java
index 8e9bc34..995b020 100644
--- a/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -85,6 +85,9 @@
   /** NoteDb ref for a group {@code refs/groups} */
   public static final String REFS_GROUPS = "refs/groups/";
 
+  /** NoteDb ref for the NoteMap of all group names */
+  public static final String REFS_GROUPNAMES = "refs/meta/group-names";
+
   /** Draft inline comments of a user on a change */
   public static final String REFS_DRAFT_COMMENTS = "refs/draft-comments/";
 
@@ -221,6 +224,10 @@
     return ref.startsWith(REFS_USERS);
   }
 
+  public static boolean isRefsGroups(String ref) {
+    return ref.startsWith(REFS_GROUPS);
+  }
+
   static Integer parseShardedRefPart(String name) {
     if (name == null) {
       return null;
diff --git a/java/com/google/gerrit/reviewdb/server/DisallowReadFromGroupsReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/DisallowReadFromGroupsReviewDbWrapper.java
new file mode 100644
index 0000000..640924c
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/server/DisallowReadFromGroupsReviewDbWrapper.java
@@ -0,0 +1,310 @@
+// 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.reviewdb.server;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public class DisallowReadFromGroupsReviewDbWrapper extends ReviewDbWrapper {
+  private static final String MSG = "This table has been migrated to NoteDb";
+
+  private final Groups groups;
+  private final GroupNames groupNames;
+  private final GroupMembers groupMembers;
+  private final GroupMemberAudits groupMemberAudits;
+  private final ByIds byIds;
+  private final ByIdAudits byIdAudits;
+
+  public DisallowReadFromGroupsReviewDbWrapper(ReviewDb db) {
+    super(db);
+    groups = new Groups(delegate.accountGroups());
+    groupNames = new GroupNames(delegate.accountGroupNames());
+    groupMembers = new GroupMembers(delegate.accountGroupMembers());
+    groupMemberAudits = new GroupMemberAudits(delegate.accountGroupMembersAudit());
+    byIds = new ByIds(delegate.accountGroupById());
+    byIdAudits = new ByIdAudits(delegate.accountGroupByIdAud());
+  }
+
+  public ReviewDb unsafeGetDelegate() {
+    return delegate;
+  }
+
+  @Override
+  public AccountGroupAccess accountGroups() {
+    return groups;
+  }
+
+  @Override
+  public AccountGroupNameAccess accountGroupNames() {
+    return groupNames;
+  }
+
+  @Override
+  public AccountGroupMemberAccess accountGroupMembers() {
+    return groupMembers;
+  }
+
+  @Override
+  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
+    return groupMemberAudits;
+  }
+
+  @Override
+  public AccountGroupByIdAccess accountGroupById() {
+    return byIds;
+  }
+
+  @Override
+  public AccountGroupByIdAudAccess accountGroupByIdAud() {
+    return byIdAudits;
+  }
+
+  private static class Groups extends AccountGroupAccessWrapper {
+    protected Groups(AccountGroupAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroup, OrmException> getAsync(
+        AccountGroup.Id key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> get(Iterable<AccountGroup.Id> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroup get(AccountGroup.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class GroupNames extends AccountGroupNameAccessWrapper {
+    protected GroupNames(AccountGroupNameAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupName, OrmException> getAsync(
+        AccountGroup.NameKey key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> get(Iterable<AccountGroup.NameKey> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupName get(AccountGroup.NameKey name) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class GroupMembers extends AccountGroupMemberAccessWrapper {
+    protected GroupMembers(AccountGroupMemberAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMember, OrmException>
+        getAsync(AccountGroupMember.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> get(Iterable<AccountGroupMember.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupMember get(AccountGroupMember.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byAccount(Account.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class GroupMemberAudits extends AccountGroupMemberAuditAccessWrapper {
+    protected GroupMemberAudits(AccountGroupMemberAuditAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMemberAudit, OrmException>
+        getAsync(AccountGroupMemberAudit.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> get(Iterable<AccountGroupMemberAudit.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroupAccount(
+        AccountGroup.Id groupId, Account.Id accountId) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class ByIds extends AccountGroupByIdAccessWrapper {
+    protected ByIds(AccountGroupByIdAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupById, OrmException> getAsync(
+        AccountGroupById.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> get(Iterable<AccountGroupById.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupById get(AccountGroupById.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> all() {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+
+  private static class ByIdAudits extends AccountGroupByIdAudAccessWrapper {
+    protected ByIdAudits(AccountGroupByIdAudAccess delegate) {
+      super(delegate);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> iterateAllEntities() {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupByIdAud, OrmException>
+        getAsync(AccountGroupByIdAud.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> get(Iterable<AccountGroupByIdAud.Key> keys) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public AccountGroupByIdAud get(AccountGroupByIdAud.Key key) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroupInclude(
+        AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) {
+      throw new UnsupportedOperationException(MSG);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) {
+      throw new UnsupportedOperationException(MSG);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/java/com/google/gerrit/reviewdb/server/ReviewDb.java
index 739d2e6..22a9cf3 100644
--- a/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDb.java
@@ -126,8 +126,4 @@
   @Sequence(startWith = FIRST_CHANGE_ID)
   @Deprecated
   int nextChangeId() throws OrmException;
-
-  default boolean changesTablesEnabled() {
-    return true;
-  }
 }
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
index bb31b1c..ef057eb 100644
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
@@ -14,8 +14,16 @@
 
 package com.google.gerrit.reviewdb.server;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.IntKey;
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.TreeSet;
 
 /** Static utilities for ReviewDb types. */
 public class ReviewDbUtil {
@@ -43,10 +51,30 @@
 
   public static ReviewDb unwrapDb(ReviewDb db) {
     if (db instanceof DisallowReadFromChangesReviewDbWrapper) {
-      return ((DisallowReadFromChangesReviewDbWrapper) db).unsafeGetDelegate();
+      return unwrapDb(((DisallowReadFromChangesReviewDbWrapper) db).unsafeGetDelegate());
+    }
+    if (db instanceof DisallowReadFromGroupsReviewDbWrapper) {
+      return unwrapDb(((DisallowReadFromGroupsReviewDbWrapper) db).unsafeGetDelegate());
     }
     return db;
   }
 
+  public static void checkColumns(Class<?> clazz, Integer... expected) {
+    Set<Integer> ids = new TreeSet<>();
+    for (Field f : clazz.getDeclaredFields()) {
+      Column col = f.getAnnotation(Column.class);
+      if (col != null) {
+        ids.add(col.id());
+      }
+    }
+    Set<Integer> expectedIds = Sets.newTreeSet(Arrays.asList(expected));
+    checkState(
+        ids.equals(expectedIds),
+        "Unexpected column set for %s: %s != %s",
+        clazz.getSimpleName(),
+        ids,
+        expectedIds);
+  }
+
   private ReviewDbUtil() {}
 }
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
index 7fd2c73..f0a8a34 100644
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
@@ -17,6 +17,12 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
@@ -32,18 +38,38 @@
 public class ReviewDbWrapper implements ReviewDb {
   protected final ReviewDb delegate;
 
+  private boolean inTransaction;
+
   protected ReviewDbWrapper(ReviewDb delegate) {
     this.delegate = checkNotNull(delegate);
   }
 
+  public boolean inTransaction() {
+    return inTransaction;
+  }
+
+  public void beginTransaction() {
+    inTransaction = true;
+  }
+
   @Override
   public void commit() throws OrmException {
-    delegate.commit();
+    if (!inTransaction) {
+      // This reads a little weird, we're not in a transaction, so why are we calling commit?
+      // Because we want to let the underlying ReviewDb do its normal thing in this case (which may
+      // be throwing an exception, or not, depending on implementation).
+      delegate.commit();
+    }
   }
 
   @Override
   public void rollback() throws OrmException {
-    delegate.rollback();
+    if (inTransaction) {
+      inTransaction = false;
+    } else {
+      // See comment in commit(): we want to let the underlying ReviewDb do its thing.
+      delegate.rollback();
+    }
   }
 
   @Override
@@ -149,11 +175,6 @@
     return delegate.nextChangeId();
   }
 
-  @Override
-  public boolean changesTablesEnabled() {
-    return delegate.changesTablesEnabled();
-  }
-
   public static class ChangeAccessWrapper implements ChangeAccess {
     protected final ChangeAccess delegate;
 
@@ -679,4 +700,591 @@
       return delegate.all();
     }
   }
+
+  public static class AccountGroupAccessWrapper implements AccountGroupAccess {
+    protected final AccountGroupAccess delegate;
+
+    protected AccountGroupAccessWrapper(AccountGroupAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroup> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroup.Id primaryKey(AccountGroup entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroup.Id, AccountGroup> toMap(Iterable<AccountGroup> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroup, OrmException> getAsync(
+        AccountGroup.Id key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> get(Iterable<AccountGroup.Id> keys) throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroup.Id> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroup> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroup.Id key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroup atomicUpdate(AccountGroup.Id key, AtomicUpdate<AccountGroup> update)
+        throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroup get(AccountGroup.Id id) throws OrmException {
+      return delegate.get(id);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) throws OrmException {
+      return delegate.byUUID(uuid);
+    }
+
+    @Override
+    public ResultSet<AccountGroup> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class AccountGroupNameAccessWrapper implements AccountGroupNameAccess {
+    protected final AccountGroupNameAccess delegate;
+
+    protected AccountGroupNameAccessWrapper(AccountGroupNameAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroup.NameKey primaryKey(AccountGroupName entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroup.NameKey, AccountGroupName> toMap(Iterable<AccountGroupName> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupName, OrmException> getAsync(
+        AccountGroup.NameKey key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> get(Iterable<AccountGroup.NameKey> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroup.NameKey> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupName> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroup.NameKey key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupName atomicUpdate(
+        AccountGroup.NameKey key, AtomicUpdate<AccountGroupName> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupName get(AccountGroup.NameKey name) throws OrmException {
+      return delegate.get(name);
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class AccountGroupMemberAccessWrapper implements AccountGroupMemberAccess {
+    protected final AccountGroupMemberAccess delegate;
+
+    protected AccountGroupMemberAccessWrapper(AccountGroupMemberAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupMember.Key primaryKey(AccountGroupMember entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupMember.Key, AccountGroupMember> toMap(Iterable<AccountGroupMember> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMember, OrmException>
+        getAsync(AccountGroupMember.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> get(Iterable<AccountGroupMember.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupMember.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupMember> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupMember.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupMember atomicUpdate(
+        AccountGroupMember.Key key, AtomicUpdate<AccountGroupMember> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupMember get(AccountGroupMember.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byAccount(Account.Id id) throws OrmException {
+      return delegate.byAccount(id);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) throws OrmException {
+      return delegate.byGroup(id);
+    }
+  }
+
+  public static class AccountGroupMemberAuditAccessWrapper
+      implements AccountGroupMemberAuditAccess {
+    protected final AccountGroupMemberAuditAccess delegate;
+
+    protected AccountGroupMemberAuditAccessWrapper(AccountGroupMemberAuditAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupMemberAudit.Key primaryKey(AccountGroupMemberAudit entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupMemberAudit.Key, AccountGroupMemberAudit> toMap(
+        Iterable<AccountGroupMemberAudit> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupMemberAudit, OrmException>
+        getAsync(AccountGroupMemberAudit.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> get(Iterable<AccountGroupMemberAudit.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupMemberAudit.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupMemberAudit> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupMemberAudit.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupMemberAudit atomicUpdate(
+        AccountGroupMemberAudit.Key key, AtomicUpdate<AccountGroupMemberAudit> update)
+        throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupMemberAudit get(AccountGroupMemberAudit.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroupAccount(
+        AccountGroup.Id groupId, Account.Id accountId) throws OrmException {
+      return delegate.byGroupAccount(groupId, accountId);
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return delegate.byGroup(groupId);
+    }
+  }
+
+  public static class AccountGroupByIdAccessWrapper implements AccountGroupByIdAccess {
+    protected final AccountGroupByIdAccess delegate;
+
+    protected AccountGroupByIdAccessWrapper(AccountGroupByIdAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupById.Key primaryKey(AccountGroupById entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupById.Key, AccountGroupById> toMap(Iterable<AccountGroupById> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupById, OrmException> getAsync(
+        AccountGroupById.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> get(Iterable<AccountGroupById.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupById.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupById> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupById.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupById atomicUpdate(
+        AccountGroupById.Key key, AtomicUpdate<AccountGroupById> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupById get(AccountGroupById.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) throws OrmException {
+      return delegate.byIncludeUUID(uuid);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) throws OrmException {
+      return delegate.byGroup(id);
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> all() throws OrmException {
+      return delegate.all();
+    }
+  }
+
+  public static class AccountGroupByIdAudAccessWrapper implements AccountGroupByIdAudAccess {
+    protected final AccountGroupByIdAudAccess delegate;
+
+    protected AccountGroupByIdAudAccessWrapper(AccountGroupByIdAudAccess delegate) {
+      this.delegate = checkNotNull(delegate);
+    }
+
+    @Override
+    public String getRelationName() {
+      return delegate.getRelationName();
+    }
+
+    @Override
+    public int getRelationID() {
+      return delegate.getRelationID();
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> iterateAllEntities() throws OrmException {
+      return delegate.iterateAllEntities();
+    }
+
+    @Override
+    public AccountGroupByIdAud.Key primaryKey(AccountGroupByIdAud entity) {
+      return delegate.primaryKey(entity);
+    }
+
+    @Override
+    public Map<AccountGroupByIdAud.Key, AccountGroupByIdAud> toMap(
+        Iterable<AccountGroupByIdAud> c) {
+      return delegate.toMap(c);
+    }
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public com.google.common.util.concurrent.CheckedFuture<AccountGroupByIdAud, OrmException>
+        getAsync(AccountGroupByIdAud.Key key) {
+      return delegate.getAsync(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> get(Iterable<AccountGroupByIdAud.Key> keys)
+        throws OrmException {
+      return delegate.get(keys);
+    }
+
+    @Override
+    public void insert(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.insert(instances);
+    }
+
+    @Override
+    public void update(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.update(instances);
+    }
+
+    @Override
+    public void upsert(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.upsert(instances);
+    }
+
+    @Override
+    public void deleteKeys(Iterable<AccountGroupByIdAud.Key> keys) throws OrmException {
+      delegate.deleteKeys(keys);
+    }
+
+    @Override
+    public void delete(Iterable<AccountGroupByIdAud> instances) throws OrmException {
+      delegate.delete(instances);
+    }
+
+    @Override
+    public void beginTransaction(AccountGroupByIdAud.Key key) throws OrmException {
+      delegate.beginTransaction(key);
+    }
+
+    @Override
+    public AccountGroupByIdAud atomicUpdate(
+        AccountGroupByIdAud.Key key, AtomicUpdate<AccountGroupByIdAud> update) throws OrmException {
+      return delegate.atomicUpdate(key, update);
+    }
+
+    @Override
+    public AccountGroupByIdAud get(AccountGroupByIdAud.Key key) throws OrmException {
+      return delegate.get(key);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroupInclude(
+        AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) throws OrmException {
+      return delegate.byGroupInclude(groupId, incGroupUUID);
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return delegate.byGroup(groupId);
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index c222756..c56caac 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.config.ScheduleConfig.MISSING_CONFIG;
 
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ScheduleConfig;
@@ -96,24 +97,42 @@
 
   @Override
   public void run() {
-    log.debug("Running account deactivations");
+    log.info("Running account deactivations");
     try {
       int numberOfAccountsDeactivated = 0;
       for (AccountState acc : accountQueryProvider.get().query(AccountPredicates.isActive())) {
-        log.debug("processing account " + acc.getUserName());
-        if (acc.getUserName() != null && !realm.isActive(acc.getUserName())) {
-          sif.deactivate(acc.getAccount().getId());
-          log.debug("deactivated accout " + acc.getUserName());
+        if (processAccount(acc)) {
           numberOfAccountsDeactivated++;
         }
       }
       log.info(
           "Deactivations complete, {} account(s) were deactivated", numberOfAccountsDeactivated);
     } catch (Exception e) {
-      log.error("Failed to deactivate inactive accounts " + e.getMessage(), e);
+      log.error("Failed to complete deactivation of accounts: " + e.getMessage(), e);
     }
   }
 
+  private boolean processAccount(AccountState account) {
+    log.debug("processing account " + account.getUserName());
+    try {
+      if (account.getUserName() != null && !realm.isActive(account.getUserName())) {
+        sif.deactivate(account.getAccount().getId());
+        log.info("deactivated account " + account.getUserName());
+        return true;
+      }
+    } catch (ResourceConflictException e) {
+      log.info("Account {} already deactivated, continuing...", account.getUserName());
+    } catch (Exception e) {
+      log.error(
+          "Error deactivating account: {} ({}) {}",
+          account.getUserName(),
+          account.getAccount().getId(),
+          e.getMessage(),
+          e);
+    }
+    return false;
+  }
+
   @Override
   public String toString() {
     return "account deactivator";
diff --git a/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/java/com/google/gerrit/server/account/AccountExternalIdCreator.java
similarity index 96%
rename from java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
rename to java/com/google/gerrit/server/account/AccountExternalIdCreator.java
index 2f8dee6..8cf4ee0 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ b/java/com/google/gerrit/server/account/AccountExternalIdCreator.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.api.accounts;
+package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
diff --git a/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java b/java/com/google/gerrit/server/account/AccountInfoComparator.java
similarity index 97%
rename from java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
rename to java/com/google/gerrit/server/account/AccountInfoComparator.java
index 7c468fc..533dece 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountInfoComparator.java
+++ b/java/com/google/gerrit/server/account/AccountInfoComparator.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.api.accounts;
+package com.google.gerrit.server.account;
 
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.Ordering;
diff --git a/java/com/google/gerrit/server/account/Accounts.java b/java/com/google/gerrit/server/account/Accounts.java
index a9428f0..8d39fb1 100644
--- a/java/com/google/gerrit/server/account/Accounts.java
+++ b/java/com/google/gerrit/server/account/Accounts.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllUsersName;
@@ -56,6 +57,7 @@
     this.emailValidator = emailValidator;
   }
 
+  @Nullable
   public Account get(Account.Id accountId) throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsersName)) {
       return read(repo, accountId);
@@ -133,6 +135,7 @@
     }
   }
 
+  @Nullable
   private Account read(Repository allUsersRepository, Account.Id accountId)
       throws IOException, ConfigInvalidException {
     AccountConfig accountConfig = new AccountConfig(emailValidator, accountId);
diff --git a/java/com/google/gerrit/server/account/CreateAccount.java b/java/com/google/gerrit/server/account/CreateAccount.java
index a5d2e7a..ed92a7e 100644
--- a/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/account/CreateAccount.java
@@ -40,7 +40,6 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
-import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.UserInitiated;
 import com.google.gerrit.server.group.db.GroupsUpdate;
diff --git a/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java b/java/com/google/gerrit/server/account/GpgApiAdapter.java
similarity index 93%
rename from java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
rename to java/com/google/gerrit/server/account/GpgApiAdapter.java
index 7def6fa..b060140 100644
--- a/java/com/google/gerrit/server/api/accounts/GpgApiAdapter.java
+++ b/java/com/google/gerrit/server/account/GpgApiAdapter.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.api.accounts;
+package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountResource;
 import java.util.List;
 import java.util.Map;
 
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index 4bc8e64..4783f29 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -52,12 +52,15 @@
       @Override
       protected void configure() {
         cache(BYID_NAME, AccountGroup.Id.class, new TypeLiteral<Optional<InternalGroup>>() {})
+            .maximumWeight(Long.MAX_VALUE)
             .loader(ByIdLoader.class);
 
         cache(BYNAME_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
+            .maximumWeight(Long.MAX_VALUE)
             .loader(ByNameLoader.class);
 
         cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
+            .maximumWeight(Long.MAX_VALUE)
             .loader(ByUUIDLoader.class);
 
         bind(GroupCacheImpl.class);
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java
index 16655ab..119dc5b 100644
--- a/java/com/google/gerrit/server/account/GroupControl.java
+++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -144,6 +144,7 @@
       return isOwner;
     }
 
+    // Keep this logic in sync with VisibleRefFilter#isOwner(...).
     if (group instanceof GroupDescription.Internal) {
       AccountGroup.UUID ownerUUID = ((GroupDescription.Internal) group).getOwnerGroupUUID();
       isOwner = getUser().getEffectiveGroups().contains(ownerUUID) || canAdministrateServer();
diff --git a/java/com/google/gerrit/server/account/GroupUUID.java b/java/com/google/gerrit/server/account/GroupUUID.java
index 45c7052..a7b32a1 100644
--- a/java/com/google/gerrit/server/account/GroupUUID.java
+++ b/java/com/google/gerrit/server/account/GroupUUID.java
@@ -25,6 +25,7 @@
     MessageDigest md = Constants.newMessageDigest();
     md.update(Constants.encode("group " + groupName + "\n"));
     md.update(Constants.encode("creator " + creator.toExternalString() + "\n"));
+    md.update(Constants.encode(String.valueOf(Math.random())));
     return new AccountGroup.UUID(ObjectId.fromRaw(md.digest()).name());
   }
 
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index f2b9e68..5dc8b19 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.project.ProjectState;
@@ -30,7 +29,9 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 
 /** Implementation of GroupBackend for the internal group system. */
@@ -75,24 +76,26 @@
   public Collection<GroupReference> suggest(String name, ProjectState project) {
     try (ReviewDb db = schema.open()) {
       return groups
-          .getAll(db)
-          // TODO(aliceks): Filter the groups by name before loading them (if possible with NoteDb).
+          .getAllGroupReferences(db)
           .filter(group -> startsWithIgnoreCase(group, name))
-          .map(InternalGroupDescription::new)
           .filter(this::isVisible)
-          .map(GroupReference::forGroup)
           .collect(toList());
-    } catch (OrmException e) {
+    } catch (OrmException | IOException | ConfigInvalidException e) {
       return ImmutableList.of();
     }
   }
 
-  private static boolean startsWithIgnoreCase(InternalGroup group, String name) {
+  private static boolean startsWithIgnoreCase(GroupReference group, String name) {
     return group.getName().regionMatches(true, 0, name, 0, name.length());
   }
 
-  private boolean isVisible(GroupDescription.Internal group) {
-    return groupControlFactory.controlFor(group).isVisible();
+  private boolean isVisible(GroupReference groupReference) {
+    return groupCache
+        .get(groupReference.getUUID())
+        .map(InternalGroupDescription::new)
+        .map(groupControlFactory::controlFor)
+        .filter(GroupControl::isVisible)
+        .isPresent();
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/account/QueryAccounts.java b/java/com/google/gerrit/server/account/QueryAccounts.java
index e6ac0f6..88f0bbc 100644
--- a/java/com/google/gerrit/server/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/account/QueryAccounts.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
-import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
diff --git a/java/com/google/gerrit/server/api/BUILD b/java/com/google/gerrit/server/api/BUILD
new file mode 100644
index 0000000..2741a0a
--- /dev/null
+++ b/java/com/google/gerrit/server/api/BUILD
@@ -0,0 +1,21 @@
+java_library(
+    name = "api",
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib:servlet-api-3_1",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/api/Module.java b/java/com/google/gerrit/server/api/GerritApiModule.java
similarity index 75%
rename from java/com/google/gerrit/server/api/Module.java
rename to java/com/google/gerrit/server/api/GerritApiModule.java
index 6214129..81226b0 100644
--- a/java/com/google/gerrit/server/api/Module.java
+++ b/java/com/google/gerrit/server/api/GerritApiModule.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.server.api;
 
 import com.google.gerrit.extensions.api.GerritApi;
-import com.google.inject.AbstractModule;
+import com.google.gerrit.extensions.api.plugins.Plugins;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.api.plugins.PluginApiImpl;
+import com.google.gerrit.server.api.plugins.PluginsImpl;
 
-public class Module extends AbstractModule {
+public class GerritApiModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(GerritApi.class).to(GerritApiImpl.class);
@@ -27,5 +30,8 @@
     install(new com.google.gerrit.server.api.config.Module());
     install(new com.google.gerrit.server.api.groups.Module());
     install(new com.google.gerrit.server.api.projects.Module());
+
+    bind(Plugins.class).to(PluginsImpl.class);
+    factory(PluginApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index f36322c..7b14725 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.server.account.GetPreferences;
 import com.google.gerrit.server.account.GetSshKeys;
 import com.google.gerrit.server.account.GetWatchedProjects;
+import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.account.Index;
 import com.google.gerrit.server.account.PostWatchedProjects;
 import com.google.gerrit.server.account.PutActive;
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 9490075..0438a23 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -129,6 +129,8 @@
     }
     lp.setFilterType(type);
 
+    lp.setAll(request.isAll());
+
     return lp.apply();
   }
 
diff --git a/java/com/google/gerrit/server/change/Abandon.java b/java/com/google/gerrit/server/change/Abandon.java
index c9d016d..c7addff 100644
--- a/java/com/google/gerrit/server/change/Abandon.java
+++ b/java/com/google/gerrit/server/change/Abandon.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
diff --git a/java/com/google/gerrit/server/git/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
similarity index 98%
rename from java/com/google/gerrit/server/git/AbandonOp.java
rename to java/com/google/gerrit/server/change/AbandonOp.java
index 8298db3..cbe5e2b 100644
--- a/java/com/google/gerrit/server/git/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.git;
+package com.google.gerrit.server.change;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ListMultimap;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 7563d99..a03f60a 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -105,9 +105,9 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.WebLinks;
+import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.api.accounts.AccountInfoComparator;
-import com.google.gerrit.server.api.accounts.GpgApiAdapter;
+import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 8f5989a..c272f6c 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -79,6 +79,7 @@
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountDeactivator;
+import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
@@ -90,13 +91,13 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
-import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.audit.AuditModule;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
 import com.google.gerrit.server.auth.oauth.OAuthTokenCache;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.cache.CacheRemovalListener;
+import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AccountPatchReviewStore;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
@@ -108,7 +109,6 @@
 import com.google.gerrit.server.events.UserScopedEventListener;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.EmailMerge;
 import com.google.gerrit.server.git.GitModule;
@@ -300,7 +300,6 @@
     bind(UiActions.class);
     install(new com.google.gerrit.server.access.Module());
     install(new com.google.gerrit.server.account.Module());
-    install(new com.google.gerrit.server.api.Module());
     install(new com.google.gerrit.server.change.Module());
     install(new com.google.gerrit.server.config.Module());
     install(new com.google.gerrit.server.group.Module());
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index fd512a5..b0f10f2 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -14,14 +14,22 @@
 
 package com.google.gerrit.server.git;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.server.CommonConverters;
+import java.io.IOException;
 import java.util.ArrayList;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Static utilities for working with {@link RevCommit}s. */
 public class CommitUtil {
-  public static CommitInfo toCommitInfo(RevCommit commit) {
+  public static CommitInfo toCommitInfo(RevCommit commit) throws IOException {
+    return toCommitInfo(commit, null);
+  }
+
+  public static CommitInfo toCommitInfo(RevCommit commit, @Nullable RevWalk walk)
+      throws IOException {
     CommitInfo info = new CommitInfo();
     info.commit = commit.getName();
     info.author = CommonConverters.toGitPerson(commit.getAuthorIdent());
@@ -30,7 +38,7 @@
     info.message = commit.getFullMessage();
     info.parents = new ArrayList<>(commit.getParentCount());
     for (int i = 0; i < commit.getParentCount(); i++) {
-      RevCommit p = commit.getParent(i);
+      RevCommit p = walk == null ? commit.getParent(i) : walk.parseCommit(commit.getParent(i));
       CommitInfo parentInfo = new CommitInfo();
       parentInfo.commit = p.getName();
       parentInfo.subject = p.getShortMessage();
diff --git a/java/com/google/gerrit/server/git/MergeOp.java b/java/com/google/gerrit/server/git/MergeOp.java
index b6fbbe2..c9ea482 100644
--- a/java/com/google/gerrit/server/git/MergeOp.java
+++ b/java/com/google/gerrit/server/git/MergeOp.java
@@ -112,7 +112,7 @@
 public class MergeOp implements AutoCloseable {
   private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
 
-  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.defaults().build();
+  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.builder().build();
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
       SUBMIT_RULE_OPTIONS.toBuilder().allowClosed(true).build();
 
diff --git a/java/com/google/gerrit/server/git/VersionedMetaData.java b/java/com/google/gerrit/server/git/VersionedMetaData.java
index 74a8134..812e693 100644
--- a/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.Nullable;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.StringReader;
@@ -76,7 +77,9 @@
     }
   }
 
-  protected RevCommit revision;
+  /** The revision at which the data was loaded. Is null for data yet to be created. */
+  @Nullable protected RevCommit revision;
+
   protected RevWalk rw;
   protected ObjectReader reader;
   protected ObjectInserter inserter;
@@ -133,7 +136,8 @@
    * @throws IOException
    * @throws ConfigInvalidException
    */
-  public void load(Repository db, ObjectId id) throws IOException, ConfigInvalidException {
+  public void load(Repository db, @Nullable ObjectId id)
+      throws IOException, ConfigInvalidException {
     try (RevWalk walk = new RevWalk(db)) {
       load(walk, id);
     }
diff --git a/java/com/google/gerrit/server/git/VisibleRefFilter.java b/java/com/google/gerrit/server/git/VisibleRefFilter.java
index ed86a92..deede71 100644
--- a/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
@@ -23,6 +24,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -30,6 +32,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -75,6 +79,7 @@
   @Nullable private final SearchingChangeCacheImpl changeCache;
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
+  private final GroupCache groupCache;
   private final PermissionBackend permissionBackend;
   private final PermissionBackend.ForProject perm;
   private final ProjectState projectState;
@@ -90,6 +95,7 @@
       @Nullable SearchingChangeCacheImpl changeCache,
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
+      GroupCache groupCache,
       PermissionBackend permissionBackend,
       @Assisted ProjectState projectState,
       @Assisted Repository git) {
@@ -98,6 +104,7 @@
     this.changeCache = changeCache;
     this.db = db;
     this.user = user;
+    this.groupCache = groupCache;
     this.permissionBackend = permissionBackend;
     this.perm =
         permissionBackend.user(user).database(db).project(projectState.getProject().getNameKey());
@@ -118,22 +125,29 @@
 
     PermissionBackend.WithUser withUser = permissionBackend.user(user);
     PermissionBackend.ForProject forProject = withUser.project(projectState.getNameKey());
-    if (checkProjectPermission(forProject, ProjectPermission.READ)) {
-      return refs;
-    } else if (checkProjectPermission(forProject, ProjectPermission.READ_NO_CONFIG)) {
-      return fastHideRefsMetaConfig(refs);
+    if (!projectState.isAllUsers()) {
+      if (checkProjectPermission(forProject, ProjectPermission.READ)) {
+        return refs;
+      } else if (checkProjectPermission(forProject, ProjectPermission.READ_NO_CONFIG)) {
+        return fastHideRefsMetaConfig(refs);
+      }
     }
 
-    Account.Id userId;
     boolean viewMetadata;
+    boolean isAdmin;
+    Account.Id userId;
+    IdentifiedUser identifiedUser;
     if (user.get().isIdentifiedUser()) {
       viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
-      IdentifiedUser u = user.get().asIdentifiedUser();
-      userId = u.getAccountId();
+      isAdmin = withUser.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+      identifiedUser = user.get().asIdentifiedUser();
+      userId = identifiedUser.getAccountId();
       userEditPrefix = RefNames.refsEditPrefix(userId);
     } else {
-      userId = null;
       viewMetadata = false;
+      isAdmin = false;
+      userId = null;
+      identifiedUser = null;
     }
 
     Map<String, Ref> result = new HashMap<>();
@@ -143,6 +157,7 @@
       String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
+      AccountGroup.UUID accountGroupUuid;
       if (name.startsWith(REFS_CACHE_AUTOMERGE) || (!showMetadata && isMetadata(name))) {
         continue;
       } else if (RefNames.isRefsEdit(name)) {
@@ -156,10 +171,19 @@
           result.put(name, ref);
         }
       } else if ((accountId = Account.Id.fromRef(name)) != null) {
-        // Account ref is visible only to corresponding account.
+        // Account ref is visible only to the corresponding account.
         if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
           result.put(name, ref);
         }
+      } else if ((accountGroupUuid = AccountGroup.UUID.fromRef(name)) != null) {
+        // Group ref is visible only to the corresponding owner group.
+        InternalGroup group = groupCache.get(accountGroupUuid).orElse(null);
+        if (viewMetadata
+            || (group != null
+                && isGroupOwner(group, identifiedUser, isAdmin)
+                && canReadRef(name))) {
+          result.put(name, ref);
+        }
       } else if (isTag(ref)) {
         // If its a tag, consider it later.
         if (ref.getObjectId() != null) {
@@ -170,8 +194,10 @@
         if (viewMetadata) {
           result.put(name, ref);
         }
-      } else if (projectState.isAllUsers() && name.equals(RefNames.REFS_EXTERNAL_IDS)) {
-        // The notes branch with the external IDs of all users must not be exposed to normal users.
+      } else if (projectState.isAllUsers()
+          && (name.equals(RefNames.REFS_EXTERNAL_IDS) || name.equals(RefNames.REFS_GROUPNAMES))) {
+        // The notes branches with the external IDs / group names must not be exposed to normal
+        // users.
         if (viewMetadata) {
           result.put(name, ref);
         }
@@ -180,6 +206,12 @@
         // symbolic we want the control around the final target. If its
         // not symbolic then getLeaf() is a no-op returning ref itself.
         result.put(name, ref);
+      } else if (isRefsUsersSelf(ref)) {
+        // viewMetadata allows to see all account refs, hence refs/users/self should be included as
+        // well
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
       }
     }
 
@@ -327,15 +359,17 @@
   }
 
   private boolean isMetadata(String name) {
-    return name.startsWith(REFS_CHANGES)
-        || RefNames.isRefsEdit(name)
-        || (projectState.isAllUsers() && name.equals(RefNames.REFS_EXTERNAL_IDS));
+    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
   }
 
   private static boolean isTag(Ref ref) {
     return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
   }
 
+  private static boolean isRefsUsersSelf(Ref ref) {
+    return ref.getName().startsWith(REFS_USERS_SELF);
+  }
+
   private boolean canReadRef(String ref) {
     try {
       perm.ref(ref).check(RefPermission.READ);
@@ -364,4 +398,13 @@
     }
     return true;
   }
+
+  private boolean isGroupOwner(
+      InternalGroup group, @Nullable IdentifiedUser user, boolean isAdmin) {
+    checkNotNull(group);
+
+    // Keep this logic in sync with GroupControl#isOwner().
+    return isAdmin
+        || (user != null && user.getEffectiveGroups().contains(group.getOwnerGroupUUID()));
+  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 417bd26..b9af127 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -778,7 +778,7 @@
     }
   }
 
-  /** Rejects updates to group branches. */
+  /** Rejects updates to group branches (refs/groups/* and refs/meta/group-names). */
   public static class GroupCommitValidator implements CommitValidationListener {
     private final AllUsersName allUsers;
 
@@ -790,6 +790,7 @@
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
       // Groups are stored inside 'refs/groups/' refs inside the 'All-Users' repository.
+      // Group names are stored inside 'refs/meta/group-names' refs inside the 'All-Users' repository.
       if (!allUsers.equals(receiveEvent.project.getNameKey())) {
         return Collections.emptyList();
       }
@@ -800,7 +801,8 @@
         return Collections.emptyList();
       }
 
-      if (receiveEvent.command.getRefName().startsWith(RefNames.REFS_GROUPS)) {
+      if (receiveEvent.command.getRefName().startsWith(RefNames.REFS_GROUPS)
+          || receiveEvent.command.getRefName().equals(RefNames.REFS_GROUPNAMES)) {
         throw new CommitValidationException("group update not allowed");
       }
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index ee6f387..22335b6 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -319,8 +319,10 @@
         IdentifiedUser caller)
         throws MergeValidationException {
       // Groups are stored inside 'refs/groups/' refs inside the 'All-Users' repository.
+      // Group names are stored inside 'refs/meta/group-names' inside the 'All-Users' repository.
       if (!allUsersName.equals(destProject.getNameKey())
-          || !destBranch.get().startsWith(RefNames.REFS_GROUPS)) {
+          || (!destBranch.get().startsWith(RefNames.REFS_GROUPS)
+              && !destBranch.get().equals(RefNames.REFS_GROUPNAMES))) {
         return;
       }
 
diff --git a/java/com/google/gerrit/server/group/AddMembers.java b/java/com/google/gerrit/server/group/AddMembers.java
index 822a36e..b57a462 100644
--- a/java/com/google/gerrit/server/group/AddMembers.java
+++ b/java/com/google/gerrit/server/group/AddMembers.java
@@ -48,7 +48,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -122,7 +122,7 @@
       throw new AuthException("Cannot add members to group " + internalGroup.getName());
     }
 
-    Set<Account.Id> newMemberIds = new HashSet<>();
+    Set<Account.Id> newMemberIds = new LinkedHashSet<>();
     for (String nameOrEmailOrId : input.members) {
       Account a = findAccount(nameOrEmailOrId);
       if (!a.isActive()) {
diff --git a/java/com/google/gerrit/server/group/AddSubgroups.java b/java/com/google/gerrit/server/group/AddSubgroups.java
index 73bea25..af0657a 100644
--- a/java/com/google/gerrit/server/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/group/AddSubgroups.java
@@ -39,7 +39,7 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.HashSet;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -102,7 +102,7 @@
     }
 
     List<GroupInfo> result = new ArrayList<>();
-    Set<AccountGroup.UUID> subgroupUuids = new HashSet<>();
+    Set<AccountGroup.UUID> subgroupUuids = new LinkedHashSet<>();
     for (String subgroupIdentifier : input.groups) {
       GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
       subgroupUuids.add(subgroup.getGroupUUID());
diff --git a/java/com/google/gerrit/server/group/GetAuditLog.java b/java/com/google/gerrit/server/group/GetAuditLog.java
index ebada0b..52a9dd9 100644
--- a/java/com/google/gerrit/server/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/group/GetAuditLog.java
@@ -31,44 +31,56 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.db.Groups;
 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.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
 
 @Singleton
 public class GetAuditLog implements RestReadView<GroupResource> {
   private final Provider<ReviewDb> db;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final AllUsersName allUsers;
   private final GroupCache groupCache;
   private final GroupJson groupJson;
   private final GroupBackend groupBackend;
   private final Groups groups;
+  private final GitRepositoryManager repoManager;
 
   @Inject
   public GetAuditLog(
       Provider<ReviewDb> db,
       AccountLoader.Factory accountLoaderFactory,
+      AllUsersName allUsers,
       GroupCache groupCache,
       GroupJson groupJson,
       GroupBackend groupBackend,
-      Groups groups) {
+      Groups groups,
+      GitRepositoryManager repoManager) {
     this.db = db;
     this.accountLoaderFactory = accountLoaderFactory;
+    this.allUsers = allUsers;
     this.groupCache = groupCache;
     this.groupJson = groupJson;
     this.groupBackend = groupBackend;
     this.groups = groups;
+    this.repoManager = repoManager;
   }
 
   @Override
   public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
-      throws AuthException, MethodNotAllowedException, OrmException {
+      throws AuthException, MethodNotAllowedException, OrmException, IOException,
+          ConfigInvalidException {
     GroupDescription.Internal group =
         rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
     if (!rsrc.getControl().isOwner()) {
@@ -79,53 +91,54 @@
 
     List<GroupAuditEventInfo> auditEvents = new ArrayList<>();
 
-    for (AccountGroupMemberAudit auditEvent :
-        groups.getMembersAudit(db.get(), group.getGroupUUID())) {
-      AccountInfo member = accountLoader.get(auditEvent.getMemberId());
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+      for (AccountGroupMemberAudit auditEvent :
+          groups.getMembersAudit(db.get(), allUsersRepo, group.getGroupUUID())) {
+        AccountInfo member = accountLoader.get(auditEvent.getMemberId());
 
-      auditEvents.add(
-          GroupAuditEventInfo.createAddUserEvent(
-              accountLoader.get(auditEvent.getAddedBy()), auditEvent.getAddedOn(), member));
-
-      if (!auditEvent.isActive()) {
         auditEvents.add(
-            GroupAuditEventInfo.createRemoveUserEvent(
-                accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
-      }
-    }
+            GroupAuditEventInfo.createAddUserEvent(
+                accountLoader.get(auditEvent.getAddedBy()), auditEvent.getAddedOn(), member));
 
-    for (AccountGroupByIdAud auditEvent :
-        groups.getSubgroupsAudit(db.get(), group.getGroupUUID())) {
-      AccountGroup.UUID includedGroupUUID = auditEvent.getIncludeUUID();
-      Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
-      GroupInfo member;
-      if (includedGroup.isPresent()) {
-        member = groupJson.format(new InternalGroupDescription(includedGroup.get()));
-      } else {
-        GroupDescription.Basic groupDescription = groupBackend.get(includedGroupUUID);
-        member = new GroupInfo();
-        member.id = Url.encode(includedGroupUUID.get());
-        member.name = groupDescription.getName();
+        if (!auditEvent.isActive()) {
+          auditEvents.add(
+              GroupAuditEventInfo.createRemoveUserEvent(
+                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+        }
       }
 
-      auditEvents.add(
-          GroupAuditEventInfo.createAddGroupEvent(
-              accountLoader.get(auditEvent.getAddedBy()),
-              auditEvent.getKey().getAddedOn(),
-              member));
+      for (AccountGroupByIdAud auditEvent :
+          groups.getSubgroupsAudit(db.get(), allUsersRepo, group.getGroupUUID())) {
+        AccountGroup.UUID includedGroupUUID = auditEvent.getIncludeUUID();
+        Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
+        GroupInfo member;
+        if (includedGroup.isPresent()) {
+          member = groupJson.format(new InternalGroupDescription(includedGroup.get()));
+        } else {
+          GroupDescription.Basic groupDescription = groupBackend.get(includedGroupUUID);
+          member = new GroupInfo();
+          member.id = Url.encode(includedGroupUUID.get());
+          member.name = groupDescription.getName();
+        }
 
-      if (!auditEvent.isActive()) {
         auditEvents.add(
-            GroupAuditEventInfo.createRemoveGroupEvent(
-                accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+            GroupAuditEventInfo.createAddGroupEvent(
+                accountLoader.get(auditEvent.getAddedBy()),
+                auditEvent.getKey().getAddedOn(),
+                member));
+
+        if (!auditEvent.isActive()) {
+          auditEvents.add(
+              GroupAuditEventInfo.createRemoveGroupEvent(
+                  accountLoader.get(auditEvent.getRemovedBy()), auditEvent.getRemovedOn(), member));
+        }
       }
     }
 
     accountLoader.fill();
 
-    // sort by date in reverse order so that the newest audit event comes first
+    // sort by date and then reverse so that the newest audit event comes first
     Collections.sort(auditEvents, comparing((GroupAuditEventInfo a) -> a.date).reversed());
-
     return auditEvents;
   }
 }
diff --git a/java/com/google/gerrit/server/group/InternalGroup.java b/java/com/google/gerrit/server/group/InternalGroup.java
index ad6fa5c..7828586 100644
--- a/java/com/google/gerrit/server/group/InternalGroup.java
+++ b/java/com/google/gerrit/server/group/InternalGroup.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import java.io.Serializable;
 import java.sql.Timestamp;
+import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
 public abstract class InternalGroup implements Serializable {
@@ -30,6 +31,14 @@
       AccountGroup accountGroup,
       ImmutableSet<Account.Id> members,
       ImmutableSet<AccountGroup.UUID> subgroups) {
+    return create(accountGroup, members, subgroups, null);
+  }
+
+  public static InternalGroup create(
+      AccountGroup accountGroup,
+      ImmutableSet<Account.Id> members,
+      ImmutableSet<AccountGroup.UUID> subgroups,
+      ObjectId refState) {
     return builder()
         .setId(accountGroup.getId())
         .setNameKey(accountGroup.getNameKey())
@@ -40,6 +49,7 @@
         .setCreatedOn(accountGroup.getCreatedOn())
         .setMembers(members)
         .setSubgroups(subgroups)
+        .setRefState(refState)
         .build();
   }
 
@@ -66,6 +76,11 @@
 
   public abstract ImmutableSet<AccountGroup.UUID> getSubgroups();
 
+  @Nullable
+  public abstract ObjectId getRefState();
+
+  public abstract Builder toBuilder();
+
   public static Builder builder() {
     return new AutoValue_InternalGroup.Builder();
   }
@@ -90,6 +105,8 @@
 
     public abstract Builder setSubgroups(ImmutableSet<AccountGroup.UUID> subgroups);
 
+    public abstract Builder setRefState(ObjectId refState);
+
     public abstract InternalGroup build();
   }
 }
diff --git a/java/com/google/gerrit/server/group/ListGroups.java b/java/com/google/gerrit/server/group/ListGroups.java
index 5c532e4..77778f3 100644
--- a/java/com/google/gerrit/server/group/ListGroups.java
+++ b/java/com/google/gerrit/server/group/ListGroups.java
@@ -46,6 +46,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Comparator;
@@ -53,12 +54,14 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Optional;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
 import java.util.function.Predicate;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.args4j.Option;
 
 /** List groups visible to the calling user. */
@@ -257,7 +260,7 @@
 
   @Override
   public SortedMap<String, GroupInfo> apply(TopLevelResource resource)
-      throws OrmException, RestApiException {
+      throws OrmException, RestApiException, IOException, ConfigInvalidException {
     SortedMap<String, GroupInfo> output = new TreeMap<>();
     for (GroupInfo info : get()) {
       output.put(MoreObjects.firstNonNull(info.name, "Group " + Url.decode(info.id)), info);
@@ -266,7 +269,8 @@
     return output;
   }
 
-  public List<GroupInfo> get() throws OrmException, RestApiException {
+  public List<GroupInfo> get()
+      throws OrmException, RestApiException, IOException, ConfigInvalidException {
     if (!Strings.isNullOrEmpty(suggest)) {
       return suggestGroups();
     }
@@ -290,13 +294,14 @@
     return getAllGroups();
   }
 
-  private List<GroupInfo> getAllGroups() throws OrmException {
+  private List<GroupInfo> getAllGroups() throws OrmException, IOException, ConfigInvalidException {
     Pattern pattern = getRegexPattern();
     Stream<GroupDescription.Internal> existingGroups =
         getAllExistingGroups()
-            // TODO(aliceks): Filter groups by UUID/name before loading them (if possible with
-            // NoteDb).
-            .filter(group -> !isNotRelevant(pattern, group))
+            .filter(group -> isRelevant(pattern, group))
+            .map(this::loadGroup)
+            .flatMap(Streams::stream)
+            .filter(this::isVisible)
             .sorted(GROUP_COMPARATOR)
             .skip(start);
     if (limit > 0) {
@@ -310,19 +315,16 @@
     return groupInfos;
   }
 
-  private Stream<GroupDescription.Internal> getAllExistingGroups() throws OrmException {
+  private Stream<GroupReference> getAllExistingGroups()
+      throws OrmException, IOException, ConfigInvalidException {
     if (!projects.isEmpty()) {
       return projects
           .stream()
           .map(ProjectState::getAllGroups)
           .flatMap(Collection::stream)
-          .map(GroupReference::getUUID)
-          .distinct()
-          .map(groupCache::get)
-          .flatMap(Streams::stream)
-          .map(InternalGroupDescription::new);
+          .distinct();
     }
-    return groups.getAll(db.get()).map(InternalGroupDescription::new);
+    return groups.getAllGroupReferences(db.get());
   }
 
   private List<GroupInfo> suggestGroups() throws OrmException, BadRequestException {
@@ -381,15 +383,15 @@
   }
 
   private List<GroupInfo> filterGroupsOwnedBy(Predicate<GroupDescription.Internal> filter)
-      throws OrmException {
+      throws OrmException, IOException, ConfigInvalidException {
     Pattern pattern = getRegexPattern();
     Stream<? extends GroupDescription.Internal> foundGroups =
         groups
-            .getAll(db.get())
-            .map(InternalGroupDescription::new)
-            // TODO(aliceks): Filter groups by UUID/name before loading them (if possible with
-            // NoteDb).
-            .filter(group -> !isNotRelevant(pattern, group))
+            .getAllGroupReferences(db.get())
+            .filter(group -> isRelevant(pattern, group))
+            .map(this::loadGroup)
+            .flatMap(Streams::stream)
+            .filter(this::isVisible)
             .filter(filter)
             .sorted(GROUP_COMPARATOR)
             .skip(start);
@@ -404,12 +406,18 @@
     return groupInfos;
   }
 
-  private List<GroupInfo> getGroupsOwnedBy(String id) throws OrmException, RestApiException {
+  private Optional<GroupDescription.Internal> loadGroup(GroupReference groupReference) {
+    return groupCache.get(groupReference.getUUID()).map(InternalGroupDescription::new);
+  }
+
+  private List<GroupInfo> getGroupsOwnedBy(String id)
+      throws OrmException, RestApiException, IOException, ConfigInvalidException {
     String uuid = groupsCollection.parse(id).getGroupUUID().get();
     return filterGroupsOwnedBy(group -> group.getOwnerGroupUUID().get().equals(uuid));
   }
 
-  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user) throws OrmException {
+  private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user)
+      throws OrmException, IOException, ConfigInvalidException {
     return filterGroupsOwnedBy(group -> isOwner(user, group));
   }
 
@@ -425,24 +433,24 @@
     return Strings.isNullOrEmpty(matchRegex) ? null : Pattern.compile(matchRegex);
   }
 
-  private boolean isNotRelevant(Pattern pattern, GroupDescription.Internal group) {
+  private boolean isRelevant(Pattern pattern, GroupReference group) {
     if (!Strings.isNullOrEmpty(matchSubstring)) {
       if (!group.getName().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US))) {
-        return true;
+        return false;
       }
     } else if (pattern != null) {
       if (!pattern.matcher(group.getName()).matches()) {
-        return true;
+        return false;
       }
     }
-    if (visibleToAll && !group.isVisibleToAll()) {
-      return true;
-    }
-    if (!groupsToInspect.isEmpty() && !groupsToInspect.contains(group.getGroupUUID())) {
-      return true;
-    }
+    return groupsToInspect.isEmpty() || groupsToInspect.contains(group.getUUID());
+  }
 
+  private boolean isVisible(GroupDescription.Internal group) {
+    if (visibleToAll && !group.isVisibleToAll()) {
+      return false;
+    }
     GroupControl c = groupControlFactory.controlFor(group);
-    return !c.isVisible();
+    return c.isVisible();
   }
 }
diff --git a/java/com/google/gerrit/server/group/ListMembers.java b/java/com/google/gerrit/server/group/ListMembers.java
index 2d069f8..fa33957 100644
--- a/java/com/google/gerrit/server/group/ListMembers.java
+++ b/java/com/google/gerrit/server/group/ListMembers.java
@@ -26,10 +26,10 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index e0884cf..91cc11c 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -38,6 +38,7 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -49,6 +50,7 @@
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -213,31 +215,29 @@
         return;
       }
 
-      Optional<InternalGroup> conflictingGroup;
+      Optional<GroupReference> conflictingGroup;
       try (ReviewDb db = schema.open()) {
         conflictingGroup =
             groups
-                .getAll(db)
-                // TODO(aliceks): Filter the groups by name as early as possible and avoid loading
-                // them (if possible with NoteDb).
+                .getAllGroupReferences(db)
                 .filter(group -> hasConfiguredName(byLowerCaseConfiguredName, group))
                 .findAny();
 
-      } catch (OrmException ignored) {
+      } catch (OrmException | IOException | ConfigInvalidException ignored) {
         return;
       }
 
       if (conflictingGroup.isPresent()) {
-        InternalGroup group = conflictingGroup.get();
+        GroupReference group = conflictingGroup.get();
         String groupName = group.getName();
         AccountGroup.UUID systemGroupUuid = byLowerCaseConfiguredName.get(groupName);
         throw new StartupException(
-            getAmbiguousNameMessage(groupName, group.getGroupUUID(), systemGroupUuid));
+            getAmbiguousNameMessage(groupName, group.getUUID(), systemGroupUuid));
       }
     }
 
     private static boolean hasConfiguredName(
-        Map<String, AccountGroup.UUID> byLowerCaseConfiguredName, InternalGroup group) {
+        Map<String, AccountGroup.UUID> byLowerCaseConfiguredName, GroupReference group) {
       String name = group.getName().toLowerCase(Locale.US);
       return byLowerCaseConfiguredName.keySet().contains(name);
     }
diff --git a/java/com/google/gerrit/server/group/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
new file mode 100644
index 0000000..c7add92
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -0,0 +1,259 @@
+// 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.group.db;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.notedb.NoteDbUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** NoteDb reader for group audit log. */
+@Singleton
+class AuditLogReader {
+  private static final Logger log = LoggerFactory.getLogger(AuditLogReader.class);
+
+  private final String serverId;
+
+  @Inject
+  AuditLogReader(@GerritServerId String serverId) {
+    this.serverId = serverId;
+  }
+
+  // Having separate methods for reading the two types of audit records mirrors the split in
+  // ReviewDb. Once ReviewDb is gone, the audit record interface becomes more flexible and we can
+  // revisit this, e.g. to do only a single walk, or even change the record types.
+
+  ImmutableList<AccountGroupMemberAudit> getMembersAudit(Repository repo, AccountGroup.UUID uuid)
+      throws IOException, ConfigInvalidException {
+    return getMembersAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
+  }
+
+  private ImmutableList<AccountGroupMemberAudit> getMembersAudit(
+      AccountGroup.Id groupId, List<ParsedCommit> commits) {
+    ListMultimap<MemberKey, AccountGroupMemberAudit> audits =
+        MultimapBuilder.hashKeys().linkedListValues().build();
+    ImmutableList.Builder<AccountGroupMemberAudit> result = ImmutableList.builder();
+    for (ParsedCommit pc : commits) {
+      for (Account.Id id : pc.addedMembers()) {
+        MemberKey key = MemberKey.create(groupId, id);
+        AccountGroupMemberAudit audit =
+            new AccountGroupMemberAudit(
+                new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
+        audits.put(key, audit);
+        result.add(audit);
+      }
+      for (Account.Id id : pc.removedMembers()) {
+        List<AccountGroupMemberAudit> adds = audits.get(MemberKey.create(groupId, id));
+        if (!adds.isEmpty()) {
+          AccountGroupMemberAudit audit = adds.remove(0);
+          audit.removed(pc.authorId(), pc.when());
+        } else {
+          // Match old behavior of DbGroupMemberAuditListener and add a "legacy" add/remove pair.
+          AccountGroupMemberAudit audit =
+              new AccountGroupMemberAudit(
+                  new AccountGroupMemberAudit.Key(id, groupId, pc.when()), pc.authorId());
+          audit.removedLegacy();
+          result.add(audit);
+        }
+      }
+    }
+    return result.build();
+  }
+
+  ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(Repository repo, AccountGroup.UUID uuid)
+      throws IOException, ConfigInvalidException {
+    return getSubgroupsAudit(getGroupId(repo, uuid), parseCommits(repo, uuid));
+  }
+
+  private ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(
+      AccountGroup.Id groupId, List<ParsedCommit> commits) {
+    ListMultimap<SubgroupKey, AccountGroupByIdAud> audits =
+        MultimapBuilder.hashKeys().linkedListValues().build();
+    ImmutableList.Builder<AccountGroupByIdAud> result = ImmutableList.builder();
+    for (ParsedCommit pc : commits) {
+      for (AccountGroup.UUID uuid : pc.addedSubgroups()) {
+        SubgroupKey key = SubgroupKey.create(groupId, uuid);
+        AccountGroupByIdAud audit =
+            new AccountGroupByIdAud(
+                new AccountGroupByIdAud.Key(groupId, uuid, pc.when()), pc.authorId());
+        audits.put(key, audit);
+        result.add(audit);
+      }
+      for (AccountGroup.UUID uuid : pc.removedSubgroups()) {
+        List<AccountGroupByIdAud> adds = audits.get(SubgroupKey.create(groupId, uuid));
+        if (!adds.isEmpty()) {
+          AccountGroupByIdAud audit = adds.remove(0);
+          audit.removed(pc.authorId(), pc.when());
+        } else {
+          // Unlike members, DbGroupMemberAuditListener didn't insert an add/remove pair here.
+        }
+      }
+    }
+    return result.build();
+  }
+
+  private Optional<ParsedCommit> parse(AccountGroup.UUID uuid, RevCommit c) {
+    Optional<Account.Id> authorId = NoteDbUtil.parseIdent(c.getAuthorIdent(), serverId);
+    if (!authorId.isPresent()) {
+      // Only report audit events from identified users, since this is a non-nullable field in
+      // ReviewDb. May be revisited after groups are fully migrated to NoteDb.
+      return Optional.empty();
+    }
+
+    List<Account.Id> addedMembers = new ArrayList<>();
+    List<AccountGroup.UUID> addedSubgroups = new ArrayList<>();
+    List<Account.Id> removedMembers = new ArrayList<>();
+    List<AccountGroup.UUID> removedSubgroups = new ArrayList<>();
+
+    for (FooterLine line : c.getFooterLines()) {
+      if (line.matches(GroupConfig.FOOTER_ADD_MEMBER)) {
+        parseAccount(uuid, c, line).ifPresent(addedMembers::add);
+      } else if (line.matches(GroupConfig.FOOTER_REMOVE_MEMBER)) {
+        parseAccount(uuid, c, line).ifPresent(removedMembers::add);
+      } else if (line.matches(GroupConfig.FOOTER_ADD_GROUP)) {
+        parseGroup(uuid, c, line).ifPresent(addedSubgroups::add);
+      } else if (line.matches(GroupConfig.FOOTER_REMOVE_GROUP)) {
+        parseGroup(uuid, c, line).ifPresent(removedSubgroups::add);
+      }
+    }
+    return Optional.of(
+        new AutoValue_AuditLogReader_ParsedCommit(
+            authorId.get(),
+            new Timestamp(c.getAuthorIdent().getWhen().getTime()),
+            ImmutableList.copyOf(addedMembers),
+            ImmutableList.copyOf(removedMembers),
+            ImmutableList.copyOf(addedSubgroups),
+            ImmutableList.copyOf(removedSubgroups)));
+  }
+
+  private Optional<Account.Id> parseAccount(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
+    Optional<Account.Id> result =
+        Optional.ofNullable(RawParseUtils.parsePersonIdent(line.getValue()))
+            .flatMap(ident -> NoteDbUtil.parseIdent(ident, serverId));
+    if (!result.isPresent()) {
+      logInvalid(uuid, c, line);
+    }
+    return result;
+  }
+
+  private static Optional<AccountGroup.UUID> parseGroup(
+      AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
+    PersonIdent ident = RawParseUtils.parsePersonIdent(line.getValue());
+    if (ident == null) {
+      logInvalid(uuid, c, line);
+      return Optional.empty();
+    }
+    return Optional.of(new AccountGroup.UUID(ident.getEmailAddress()));
+  }
+
+  private static void logInvalid(AccountGroup.UUID uuid, RevCommit c, FooterLine line) {
+    log.debug(
+        "Invalid footer line in commit {} while parsing audit log for group {}: {}",
+        c.name(),
+        uuid,
+        line);
+  }
+
+  private ImmutableList<ParsedCommit> parseCommits(Repository repo, AccountGroup.UUID uuid)
+      throws IOException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
+      if (ref == null) {
+        return ImmutableList.of();
+      }
+
+      rw.reset();
+      rw.markStart(rw.parseCommit(ref.getObjectId()));
+      rw.setRetainBody(true);
+      rw.sort(RevSort.COMMIT_TIME_DESC, true);
+      rw.sort(RevSort.REVERSE, true);
+
+      ImmutableList.Builder<ParsedCommit> result = ImmutableList.builder();
+      RevCommit c;
+      while ((c = rw.next()) != null) {
+        parse(uuid, c).ifPresent(result::add);
+      }
+      return result.build();
+    }
+  }
+
+  private AccountGroup.Id getGroupId(Repository repo, AccountGroup.UUID uuid)
+      throws ConfigInvalidException, IOException {
+    // TODO(dborowitz): This re-walks all commits just to find createdOn, which we don't need.
+    return GroupConfig.loadForGroup(repo, uuid).getLoadedGroup().get().getId();
+  }
+
+  @AutoValue
+  abstract static class MemberKey {
+    static MemberKey create(AccountGroup.Id groupId, Account.Id memberId) {
+      return new AutoValue_AuditLogReader_MemberKey(groupId, memberId);
+    }
+
+    abstract AccountGroup.Id groupId();
+
+    abstract Account.Id memberId();
+  }
+
+  @AutoValue
+  abstract static class SubgroupKey {
+    static SubgroupKey create(AccountGroup.Id groupId, AccountGroup.UUID subgroupUuid) {
+      return new AutoValue_AuditLogReader_SubgroupKey(groupId, subgroupUuid);
+    }
+
+    abstract AccountGroup.Id groupId();
+
+    abstract AccountGroup.UUID subgroupUuid();
+  }
+
+  @AutoValue
+  abstract static class ParsedCommit {
+    abstract Account.Id authorId();
+
+    abstract Timestamp when();
+
+    abstract ImmutableList<Account.Id> addedMembers();
+
+    abstract ImmutableList<Account.Id> removedMembers();
+
+    abstract ImmutableList<AccountGroup.UUID> addedSubgroups();
+
+    abstract ImmutableList<AccountGroup.UUID> removedSubgroups();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupBundle.java b/java/com/google/gerrit/server/group/db/GroupBundle.java
new file mode 100644
index 0000000..642a6bd
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupBundle.java
@@ -0,0 +1,235 @@
+// 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.group.db;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gwtorm.server.OrmException;
+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.Repository;
+
+/**
+ * A bundle of all entities rooted at a single {@link AccountGroup} entity.
+ *
+ * <p>Used primarily during the migration process. Most callers should prefer {@link InternalGroup}
+ * instead.
+ */
+@AutoValue
+public abstract class GroupBundle {
+  static {
+    // Initialization-time checks that the column set hasn't changed since the
+    // last time this file was updated.
+    checkColumns(AccountGroup.NameKey.class, 1);
+    checkColumns(AccountGroup.UUID.class, 1);
+    checkColumns(AccountGroup.Id.class, 1);
+    checkColumns(AccountGroup.class, 1, 2, 4, 7, 9, 10, 11);
+
+    checkColumns(AccountGroupById.Key.class, 1, 2);
+    checkColumns(AccountGroupById.class, 1);
+
+    checkColumns(AccountGroupByIdAud.Key.class, 1, 2, 3);
+    checkColumns(AccountGroupByIdAud.class, 1, 2, 3, 4);
+
+    checkColumns(AccountGroupMember.Key.class, 1, 2);
+    checkColumns(AccountGroupMember.class, 1);
+
+    checkColumns(AccountGroupMemberAudit.Key.class, 1, 2, 3);
+    checkColumns(AccountGroupMemberAudit.class, 1, 2, 3, 4);
+  }
+
+  @Singleton
+  public static class Factory {
+    private final AuditLogReader auditLogReader;
+
+    @Inject
+    Factory(AuditLogReader auditLogReader) {
+      this.auditLogReader = auditLogReader;
+    }
+
+    public GroupBundle fromReviewDb(ReviewDb db, AccountGroup.Id id) throws OrmException {
+      AccountGroup group = db.accountGroups().get(id);
+      if (group == null) {
+        throw new OrmException("Group " + id + " not found");
+      }
+      return create(
+          group,
+          db.accountGroupMembers().byGroup(id),
+          db.accountGroupMembersAudit().byGroup(id),
+          db.accountGroupById().byGroup(id),
+          db.accountGroupByIdAud().byGroup(id));
+    }
+
+    public GroupBundle fromNoteDb(Repository repo, AccountGroup.UUID uuid)
+        throws ConfigInvalidException, IOException {
+      GroupConfig groupConfig = GroupConfig.loadForGroup(repo, uuid);
+      InternalGroup internalGroup = groupConfig.getLoadedGroup().get();
+      AccountGroup.Id groupId = internalGroup.getId();
+
+      AccountGroup accountGroup =
+          new AccountGroup(
+              internalGroup.getNameKey(),
+              internalGroup.getId(),
+              internalGroup.getGroupUUID(),
+              internalGroup.getCreatedOn());
+      accountGroup.setDescription(internalGroup.getDescription());
+      accountGroup.setOwnerGroupUUID(internalGroup.getOwnerGroupUUID());
+      accountGroup.setVisibleToAll(internalGroup.isVisibleToAll());
+
+      return create(
+          accountGroup,
+          internalGroup
+              .getMembers()
+              .stream()
+              .map(
+                  accountId ->
+                      new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId)))
+              .collect(toImmutableSet()),
+          auditLogReader.getMembersAudit(repo, uuid),
+          internalGroup
+              .getSubgroups()
+              .stream()
+              .map(
+                  subgroupUuid ->
+                      new AccountGroupById(new AccountGroupById.Key(groupId, subgroupUuid)))
+              .collect(toImmutableSet()),
+          auditLogReader.getSubgroupsAudit(repo, uuid));
+    }
+  }
+
+  public static GroupBundle create(
+      AccountGroup group,
+      Iterable<AccountGroupMember> members,
+      Iterable<AccountGroupMemberAudit> memberAudit,
+      Iterable<AccountGroupById> byId,
+      Iterable<AccountGroupByIdAud> byIdAudit) {
+    return new AutoValue_GroupBundle.Builder()
+        .group(group)
+        .members(members)
+        .memberAudit(memberAudit)
+        .byId(byId)
+        .byIdAudit(byIdAudit)
+        .build();
+  }
+
+  static Builder builder() {
+    return new AutoValue_GroupBundle.Builder().members().memberAudit().byId().byIdAudit();
+  }
+
+  public AccountGroup.Id id() {
+    return group().getId();
+  }
+
+  public AccountGroup.UUID uuid() {
+    return group().getGroupUUID();
+  }
+
+  public abstract AccountGroup group();
+
+  public abstract ImmutableSet<AccountGroupMember> members();
+
+  public abstract ImmutableSet<AccountGroupMemberAudit> memberAudit();
+
+  public abstract ImmutableSet<AccountGroupById> byId();
+
+  public abstract ImmutableSet<AccountGroupByIdAud> byIdAudit();
+
+  public abstract Builder toBuilder();
+
+  public GroupBundle truncateToSecond() {
+    AccountGroup newGroup = new AccountGroup(group());
+    if (newGroup.getCreatedOn() != null) {
+      newGroup.setCreatedOn(TimeUtil.truncateToSecond(newGroup.getCreatedOn()));
+    }
+    return toBuilder()
+        .group(newGroup)
+        .memberAudit(
+            memberAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
+        .byIdAudit(
+            byIdAudit().stream().map(GroupBundle::truncateToSecond).collect(toImmutableSet()))
+        .build();
+  }
+
+  private static AccountGroupMemberAudit truncateToSecond(AccountGroupMemberAudit a) {
+    AccountGroupMemberAudit result =
+        new AccountGroupMemberAudit(
+            new AccountGroupMemberAudit.Key(
+                a.getKey().getParentKey(),
+                a.getKey().getGroupId(),
+                TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
+            a.getAddedBy());
+    if (a.getRemovedOn() != null) {
+      result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
+    }
+    return result;
+  }
+
+  private static AccountGroupByIdAud truncateToSecond(AccountGroupByIdAud a) {
+    AccountGroupByIdAud result =
+        new AccountGroupByIdAud(
+            new AccountGroupByIdAud.Key(
+                a.getKey().getParentKey(),
+                a.getKey().getIncludeUUID(),
+                TimeUtil.truncateToSecond(a.getKey().getAddedOn())),
+            a.getAddedBy());
+    if (a.getRemovedOn() != null) {
+      result.removed(a.getRemovedBy(), TimeUtil.truncateToSecond(a.getRemovedOn()));
+    }
+    return result;
+  }
+
+  public InternalGroup toInternalGroup() {
+    return InternalGroup.create(
+        group(),
+        members().stream().map(AccountGroupMember::getAccountId).collect(toImmutableSet()),
+        byId().stream().map(AccountGroupById::getIncludeUUID).collect(toImmutableSet()));
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder group(AccountGroup group);
+
+    abstract Builder members(AccountGroupMember... member);
+
+    abstract Builder members(Iterable<AccountGroupMember> member);
+
+    abstract Builder memberAudit(AccountGroupMemberAudit... audit);
+
+    abstract Builder memberAudit(Iterable<AccountGroupMemberAudit> audit);
+
+    abstract Builder byId(AccountGroupById... byId);
+
+    abstract Builder byId(Iterable<AccountGroupById> byId);
+
+    abstract Builder byIdAudit(AccountGroupByIdAud... audit);
+
+    abstract Builder byIdAudit(Iterable<AccountGroupByIdAud> audit);
+
+    abstract GroupBundle build();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 6c57b67..c23dc09 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.VersionedMetaData;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -40,14 +41,22 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterKey;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevSort;
 
 // TODO(aliceks): Add Javadoc descriptions to this file.
 public class GroupConfig extends VersionedMetaData {
-  private static final String GROUP_CONFIG_FILE = "group.config";
+  public static final String GROUP_CONFIG_FILE = "group.config";
+
+  static final FooterKey FOOTER_ADD_MEMBER = new FooterKey("Add");
+  static final FooterKey FOOTER_REMOVE_MEMBER = new FooterKey("Remove");
+  static final FooterKey FOOTER_ADD_GROUP = new FooterKey("Add-group");
+  static final FooterKey FOOTER_REMOVE_GROUP = new FooterKey("Remove-group");
+
   private static final String MEMBERS_FILE = "members";
   private static final String SUBGROUPS_FILE = "subgroups";
   private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
@@ -88,8 +97,7 @@
     return loadedGroup;
   }
 
-  private void setGroupCreation(InternalGroupCreation groupCreation)
-      throws OrmDuplicateKeyException {
+  void setGroupCreation(InternalGroupCreation groupCreation) throws OrmDuplicateKeyException {
     checkLoaded();
     if (loadedGroup.isPresent()) {
       throw new OrmDuplicateKeyException(String.format("Group %s already exists", groupUuid.get()));
@@ -124,7 +132,9 @@
       Config config = readConfig(GROUP_CONFIG_FILE);
       ImmutableSet<Account.Id> members = readMembers();
       ImmutableSet<AccountGroup.UUID> subgroups = readSubgroups();
-      loadedGroup = Optional.of(createFrom(groupUuid, config, members, subgroups, createdOn));
+      loadedGroup =
+          Optional.of(
+              createFrom(groupUuid, config, members, subgroups, createdOn, revision.toObjectId()));
     }
 
     isLoaded = true;
@@ -135,7 +145,8 @@
       Config config,
       ImmutableSet<Account.Id> members,
       ImmutableSet<AccountGroup.UUID> subgroups,
-      Timestamp createdOn) {
+      Timestamp createdOn,
+      ObjectId refState) {
     InternalGroup.Builder group = InternalGroup.builder();
     group.setGroupUUID(groupUuid);
     Arrays.stream(GroupConfigEntry.values())
@@ -143,10 +154,18 @@
     group.setMembers(members);
     group.setSubgroups(subgroups);
     group.setCreatedOn(createdOn);
+    group.setRefState(refState);
     return group.build();
   }
 
   @Override
+  public RevCommit commit(MetaDataUpdate update) throws IOException {
+    RevCommit c = super.commit(update);
+    loadedGroup = Optional.of(loadedGroup.get().toBuilder().setRefState(c.toObjectId()).build());
+    return c;
+  }
+
+  @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
     if (!groupCreation.isPresent() && !groupUpdate.isPresent()) {
@@ -185,7 +204,8 @@
                 config,
                 updatedMembers.orElse(originalMembers),
                 updatedSubgroups.orElse(originalSubgroups),
-                createdOn));
+                createdOn,
+                null));
     groupCreation = Optional.empty();
 
     return true;
@@ -319,12 +339,12 @@
         Sets.difference(oldMembers, newMembers)
             .stream()
             .map(accountNameEmailRetriever)
-            .map("Remove: "::concat);
+            .map((FOOTER_REMOVE_MEMBER.getName() + ": ")::concat);
     Stream<String> addedMembers =
         Sets.difference(newMembers, oldMembers)
             .stream()
             .map(accountNameEmailRetriever)
-            .map("Add: "::concat);
+            .map((FOOTER_ADD_MEMBER.getName() + ": ")::concat);
     return Stream.concat(removedMembers, addedMembers);
   }
 
@@ -334,12 +354,12 @@
         Sets.difference(oldSubgroups, newSubgroups)
             .stream()
             .map(groupNameRetriever)
-            .map("Remove-group: "::concat);
+            .map((FOOTER_REMOVE_GROUP.getName() + ": ")::concat);
     Stream<String> addedMembers =
         Sets.difference(newSubgroups, oldSubgroups)
             .stream()
             .map(groupNameRetriever)
-            .map("Add-group: "::concat);
+            .map((FOOTER_ADD_GROUP.getName() + ": ")::concat);
     return Stream.concat(removedMembers, addedMembers);
   }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index 025b0e9..0daceb3 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -32,7 +32,11 @@
     @Override
     void initNewConfig(Config config, InternalGroupCreation group) {
       AccountGroup.Id id = group.getId();
-      config.setInt(SECTION_NAME, null, super.keyName, id.get());
+
+      // Do not use config.setInt(...) to write the group ID because config.setInt(...) persists
+      // integers that can be expressed in KiB as a unit strings, e.g. "1024" is stored as "1k".
+      // Using config.setString(...) ensures that group IDs are human readable.
+      config.setString(SECTION_NAME, null, super.keyName, Integer.toString(id.get()));
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
new file mode 100644
index 0000000..ee7f849
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -0,0 +1,312 @@
+// 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.group.db;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.collect.ImmutableBiMap.toImmutableBiMap;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableBiMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+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;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+// TODO(aliceks): Add Javadoc descriptions.
+public class GroupNameNotes extends VersionedMetaData {
+  private static final String SECTION_NAME = "group";
+  private static final String UUID_PARAM = "uuid";
+  private static final String NAME_PARAM = "name";
+
+  @VisibleForTesting
+  static final String UNIQUE_REF_ERROR = "GroupReference collection must contain unique references";
+
+  public static void updateGroupNames(
+      Repository allUsersRepo,
+      ObjectInserter inserter,
+      BatchRefUpdate bru,
+      Collection<GroupReference> groupReferences,
+      PersonIdent ident)
+      throws IOException {
+    // Not strictly necessary for iteration; throws IAE if it encounters duplicates, which is nice.
+    ImmutableBiMap<AccountGroup.UUID, String> biMap = toBiMap(groupReferences);
+
+    try (ObjectReader reader = inserter.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      // Always start from an empty map, discarding old notes.
+      NoteMap noteMap = NoteMap.newEmptyMap();
+      Ref ref = allUsersRepo.exactRef(RefNames.REFS_GROUPNAMES);
+      RevCommit oldCommit = ref != null ? rw.parseCommit(ref.getObjectId()) : null;
+
+      for (Map.Entry<AccountGroup.UUID, String> e : biMap.entrySet()) {
+        AccountGroup.NameKey nameKey = new AccountGroup.NameKey(e.getValue());
+        ObjectId noteKey = getNoteKey(nameKey);
+        noteMap.set(noteKey, getAsNoteData(e.getKey(), nameKey), inserter);
+      }
+
+      ObjectId newTreeId = noteMap.writeTree(inserter);
+      if (oldCommit != null && newTreeId.equals(oldCommit.getTree())) {
+        return;
+      }
+      CommitBuilder cb = new CommitBuilder();
+      if (oldCommit != null) {
+        cb.addParentId(oldCommit);
+      }
+      cb.setTreeId(newTreeId);
+      cb.setAuthor(ident);
+      cb.setCommitter(ident);
+      int n = groupReferences.size();
+      cb.setMessage("Store " + n + " group name" + (n != 1 ? "s" : ""));
+      ObjectId newId = inserter.insert(cb).copy();
+
+      ObjectId oldId = oldCommit != null ? oldCommit.copy() : ObjectId.zeroId();
+      bru.addCommand(new ReceiveCommand(oldId, newId, RefNames.REFS_GROUPNAMES));
+    }
+  }
+
+  private static ImmutableBiMap<AccountGroup.UUID, String> toBiMap(
+      Collection<GroupReference> groupReferences) {
+    try {
+      return groupReferences
+          .stream()
+          .collect(toImmutableBiMap(gr -> gr.getUUID(), gr -> gr.getName()));
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException(UNIQUE_REF_ERROR, e);
+    }
+  }
+
+  private final AccountGroup.UUID groupUuid;
+  private final Optional<AccountGroup.NameKey> oldGroupName;
+  private final Optional<AccountGroup.NameKey> newGroupName;
+
+  private boolean nameConflicting;
+
+  private GroupNameNotes(
+      AccountGroup.UUID groupUuid,
+      @Nullable AccountGroup.NameKey oldGroupName,
+      @Nullable AccountGroup.NameKey newGroupName) {
+    this.groupUuid = checkNotNull(groupUuid);
+
+    if (Objects.equals(oldGroupName, newGroupName)) {
+      this.oldGroupName = Optional.empty();
+      this.newGroupName = Optional.empty();
+    } else {
+      this.oldGroupName = Optional.ofNullable(oldGroupName);
+      this.newGroupName = Optional.ofNullable(newGroupName);
+    }
+  }
+
+  public static GroupNameNotes loadForRename(
+      Repository repository,
+      AccountGroup.UUID groupUuid,
+      AccountGroup.NameKey oldName,
+      AccountGroup.NameKey newName)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    checkNotNull(oldName);
+    checkNotNull(newName);
+
+    GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, oldName, newName);
+    groupNameNotes.load(repository);
+    groupNameNotes.ensureNewNameIsNotUsed();
+    return groupNameNotes;
+  }
+
+  public static GroupNameNotes loadForNewGroup(
+      Repository repository, AccountGroup.UUID groupUuid, AccountGroup.NameKey groupName)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    checkNotNull(groupName);
+
+    GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, null, groupName);
+    groupNameNotes.load(repository);
+    groupNameNotes.ensureNewNameIsNotUsed();
+    return groupNameNotes;
+  }
+
+  public static ImmutableSet<GroupReference> loadAllGroupReferences(Repository repository)
+      throws IOException, ConfigInvalidException {
+    Ref ref = repository.exactRef(RefNames.REFS_GROUPNAMES);
+    if (ref == null) {
+      return ImmutableSet.of();
+    }
+    try (RevWalk revWalk = new RevWalk(repository);
+        ObjectReader reader = revWalk.getObjectReader()) {
+      RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
+      NoteMap noteMap = NoteMap.read(reader, notesCommit);
+      ImmutableSet.Builder<GroupReference> groupReferences = ImmutableSet.builder();
+      for (Note note : noteMap) {
+        GroupReference groupReference = getGroupReference(reader, note.getData());
+        groupReferences.add(groupReference);
+      }
+      return groupReferences.build();
+    }
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.REFS_GROUPNAMES;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    nameConflicting = false;
+
+    if (revision != null) {
+      NoteMap noteMap = NoteMap.read(reader, revision);
+      if (newGroupName.isPresent()) {
+        ObjectId newNameId = getNoteKey(newGroupName.get());
+        nameConflicting = noteMap.contains(newNameId);
+      }
+      ensureOldNameIsPresent(noteMap);
+    }
+  }
+
+  private void ensureOldNameIsPresent(NoteMap noteMap) throws IOException, ConfigInvalidException {
+    if (oldGroupName.isPresent()) {
+      AccountGroup.NameKey oldName = oldGroupName.get();
+      ObjectId noteKey = getNoteKey(oldName);
+      ObjectId noteDataBlobId = noteMap.get(noteKey);
+      if (noteDataBlobId == null) {
+        throw new ConfigInvalidException(
+            String.format("Group name '%s' doesn't exist in the list of all names", oldName));
+      }
+      GroupReference group = getGroupReference(reader, noteDataBlobId);
+      AccountGroup.UUID foundUuid = group.getUUID();
+      if (!Objects.equals(groupUuid, foundUuid)) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Name '%s' points to UUID '%s' and not to '%s'", oldName, foundUuid, groupUuid));
+      }
+    }
+  }
+
+  private void ensureNewNameIsNotUsed() throws OrmDuplicateKeyException {
+    if (newGroupName.isPresent() && nameConflicting) {
+      throw new OrmDuplicateKeyException(
+          String.format("Name '%s' is already used", newGroupName.get().get()));
+    }
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    if (!oldGroupName.isPresent() && !newGroupName.isPresent()) {
+      return false;
+    }
+
+    NoteMap noteMap = revision == null ? NoteMap.newEmptyMap() : NoteMap.read(reader, revision);
+    if (oldGroupName.isPresent()) {
+      removeNote(noteMap, oldGroupName.get(), inserter);
+    }
+
+    if (newGroupName.isPresent()) {
+      addNote(noteMap, newGroupName.get(), groupUuid, inserter);
+    }
+
+    commit.setTreeId(noteMap.writeTree(inserter));
+    commit.setMessage(getCommitMessage());
+
+    return true;
+  }
+
+  private static void removeNote(
+      NoteMap noteMap, AccountGroup.NameKey groupName, ObjectInserter inserter) throws IOException {
+    ObjectId noteKey = getNoteKey(groupName);
+    noteMap.set(noteKey, null, inserter);
+  }
+
+  private static void addNote(
+      NoteMap noteMap,
+      AccountGroup.NameKey groupName,
+      AccountGroup.UUID groupUuid,
+      ObjectInserter inserter)
+      throws IOException {
+    ObjectId noteKey = getNoteKey(groupName);
+    noteMap.set(noteKey, getAsNoteData(groupUuid, groupName), inserter);
+  }
+
+  // Use the same approach as ExternalId.Key.sha1().
+  @SuppressWarnings("deprecation")
+  @VisibleForTesting
+  static ObjectId getNoteKey(AccountGroup.NameKey groupName) {
+    return ObjectId.fromRaw(Hashing.sha1().hashString(groupName.get(), UTF_8).asBytes());
+  }
+
+  private static String getAsNoteData(AccountGroup.UUID uuid, AccountGroup.NameKey groupName) {
+    Config config = new Config();
+    config.setString(SECTION_NAME, null, UUID_PARAM, uuid.get());
+    config.setString(SECTION_NAME, null, NAME_PARAM, groupName.get());
+    return config.toText();
+  }
+
+  @VisibleForTesting
+  public static GroupReference getGroupReference(ObjectReader reader, ObjectId noteDataBlobId)
+      throws IOException, ConfigInvalidException {
+    byte[] noteData = reader.open(noteDataBlobId, OBJ_BLOB).getCachedBytes();
+    return getFromNoteData(noteData);
+  }
+
+  private static GroupReference getFromNoteData(byte[] noteData) throws ConfigInvalidException {
+    Config config = new Config();
+    config.fromText(new String(noteData, UTF_8));
+
+    String uuid = config.getString(SECTION_NAME, null, UUID_PARAM);
+    String name = config.getString(SECTION_NAME, null, NAME_PARAM);
+    if (uuid == null || name == null) {
+      throw new ConfigInvalidException(
+          String.format("UUID '%s' and name '%s' must be defined", uuid, name));
+    }
+
+    return new GroupReference(new AccountGroup.UUID(uuid), name);
+  }
+
+  private String getCommitMessage() {
+    if (oldGroupName.isPresent() && newGroupName.isPresent()) {
+      return String.format(
+          "Rename group from '%s' to '%s'", oldGroupName.get(), newGroupName.get());
+    }
+    if (newGroupName.isPresent()) {
+      return String.format("Create group '%s'", newGroupName.get());
+    }
+    if (oldGroupName.isPresent()) {
+      return String.format("Delete group '%s'", oldGroupName.get());
+    }
+    return "No-op";
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/GroupRebuilder.java b/java/com/google/gerrit/server/group/db/GroupRebuilder.java
new file mode 100644
index 0000000..7f8d8a9
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/GroupRebuilder.java
@@ -0,0 +1,331 @@
+// 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.group.db;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.VersionedMetaData.BatchMetaDataUpdate;
+import com.google.gerrit.server.group.db.InternalGroupUpdate.MemberModification;
+import com.google.gerrit.server.group.db.InternalGroupUpdate.SubgroupModification;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.Map;
+import java.util.NavigableSet;
+import java.util.Optional;
+import java.util.function.BiFunction;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Helper for rebuilding an entire group's NoteDb refs. */
+@Singleton
+public class GroupRebuilder {
+  private final Provider<PersonIdent> serverIdent;
+  private final AllUsersName allUsers;
+  private final MetaDataUpdate.InternalFactory metaDataUpdateFactory;
+
+  private final BiFunction<Account.Id, PersonIdent, PersonIdent> newPersonIdentFunc;
+  private final Function<Account.Id, String> getAccountNameEmailFunc;
+  private final Function<AccountGroup.UUID, String> getGroupNameFunc;
+
+  @Inject
+  GroupRebuilder(
+      @AnonymousCowardName String anonymousCowardName,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
+      @GerritServerId String serverId,
+      AllUsersName allUsers,
+      MetaDataUpdate.InternalFactory metaDataUpdateFactory,
+      AccountCache accountCache,
+      GroupCache groupCache) {
+    this(
+        serverIdent,
+        allUsers,
+        metaDataUpdateFactory,
+
+        // TODO(dborowitz): These probably won't work during init.
+        (id, ident) ->
+            new PersonIdent(
+                GroupsUpdate.getAccountName(accountCache, anonymousCowardName, id),
+                GroupsUpdate.getEmailForAuditLog(id, serverId),
+                ident.getWhen(),
+                ident.getTimeZone()),
+        id -> GroupsUpdate.getAccountNameEmail(accountCache, anonymousCowardName, id, serverId),
+        uuid -> GroupsUpdate.getGroupName(groupCache, uuid));
+  }
+
+  @VisibleForTesting
+  GroupRebuilder(
+      Provider<PersonIdent> serverIdent,
+      AllUsersName allUsers,
+      MetaDataUpdate.InternalFactory metaDataUpdateFactory,
+      BiFunction<Account.Id, PersonIdent, PersonIdent> newPersonIdentFunc,
+      Function<Account.Id, String> getAccountNameEmailFunc,
+      Function<AccountGroup.UUID, String> getGroupNameFunc) {
+    this.serverIdent = serverIdent;
+    this.allUsers = allUsers;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.newPersonIdentFunc = newPersonIdentFunc;
+    this.getAccountNameEmailFunc = getAccountNameEmailFunc;
+    this.getGroupNameFunc = getGroupNameFunc;
+  }
+
+  public void rebuild(Repository allUsersRepo, GroupBundle bundle, @Nullable BatchRefUpdate bru)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, bundle.uuid());
+    AccountGroup group = bundle.group();
+    groupConfig.setGroupCreation(
+        InternalGroupCreation.builder()
+            .setId(bundle.id())
+            .setNameKey(group.getNameKey())
+            .setGroupUUID(group.getGroupUUID())
+            .setCreatedOn(group.getCreatedOn())
+            .build());
+
+    InternalGroupUpdate.Builder updateBuilder =
+        InternalGroupUpdate.builder()
+            .setOwnerGroupUUID(group.getOwnerGroupUUID())
+            .setVisibleToAll(group.isVisibleToAll());
+    if (bundle.group().getDescription() != null) {
+      updateBuilder.setDescription(group.getDescription());
+    }
+    groupConfig.setGroupUpdate(updateBuilder.build(), getAccountNameEmailFunc, getGroupNameFunc);
+
+    Map<Key, Collection<Event>> events = toEvents(bundle).asMap();
+    PersonIdent nowServerIdent = getServerIdent(events);
+
+    MetaDataUpdate md = metaDataUpdateFactory.create(allUsers, allUsersRepo, bru);
+
+    // Creation is done by the server (unlike later audit events).
+    PersonIdent created = new PersonIdent(nowServerIdent, group.getCreatedOn());
+    md.getCommitBuilder().setAuthor(created);
+    md.getCommitBuilder().setCommitter(created);
+
+    // Rebuild group ref.
+    try (BatchMetaDataUpdate batch = groupConfig.openUpdate(md)) {
+      batch.write(groupConfig, md.getCommitBuilder());
+
+      for (Map.Entry<Key, Collection<Event>> e : events.entrySet()) {
+        InternalGroupUpdate.Builder ub = InternalGroupUpdate.builder();
+        e.getValue().forEach(event -> event.update().accept(ub));
+        groupConfig.setGroupUpdate(ub.build(), getAccountNameEmailFunc, getGroupNameFunc);
+
+        PersonIdent currServerIdent = new PersonIdent(nowServerIdent, e.getKey().when());
+        CommitBuilder cb = new CommitBuilder();
+        cb.setAuthor(
+            e.getKey()
+                .accountId()
+                .map(id -> newPersonIdentFunc.apply(id, currServerIdent))
+                .orElse(currServerIdent));
+        cb.setCommitter(currServerIdent);
+        batch.write(groupConfig, cb);
+      }
+
+      batch.createRef(groupConfig.getRefName());
+    }
+  }
+
+  private ListMultimap<Key, Event> toEvents(GroupBundle bundle) {
+    ListMultimap<Key, Event> result =
+        MultimapBuilder.treeKeys(Key.COMPARATOR).arrayListValues(1).build();
+    Event e;
+
+    for (AccountGroupMemberAudit a : bundle.memberAudit()) {
+      checkArgument(
+          a.getKey().getGroupId().equals(bundle.id()),
+          "key %s does not match group %s",
+          a.getKey(),
+          bundle.id());
+      Account.Id accountId = a.getKey().getParentKey();
+      e = event(Type.ADD_MEMBER, a.getAddedBy(), a.getKey().getAddedOn(), addMember(accountId));
+      result.put(e.key(), e);
+      if (!a.isActive()) {
+        e = event(Type.REMOVE_MEMBER, a.getRemovedBy(), a.getRemovedOn(), removeMember(accountId));
+        result.put(e.key(), e);
+      }
+    }
+
+    for (AccountGroupByIdAud a : bundle.byIdAudit()) {
+      checkArgument(
+          a.getKey().getParentKey().equals(bundle.id()),
+          "key %s does not match group %s",
+          a.getKey(),
+          bundle.id());
+      AccountGroup.UUID uuid = a.getKey().getIncludeUUID();
+      e = event(Type.ADD_GROUP, a.getAddedBy(), a.getKey().getAddedOn(), addGroup(uuid));
+      result.put(e.key(), e);
+      if (!a.isActive()) {
+        e = event(Type.REMOVE_GROUP, a.getRemovedBy(), a.getRemovedOn(), removeGroup(uuid));
+        result.put(e.key(), e);
+      }
+    }
+
+    // Due to clock skew, audit events may be in the future relative to this machine. Ensure the
+    // fixup event happens after any other events, both for the purposes of sorting Keys correctly
+    // and to avoid non-monotonic timestamps in the commit history.
+    Timestamp maxTs =
+        Stream.concat(result.keySet().stream().map(Key::when), Stream.of(TimeUtil.nowTs()))
+            .max(Comparator.naturalOrder())
+            .get();
+    Timestamp fixupTs = new Timestamp(maxTs.getTime() + 1);
+    e = serverEvent(Type.FIXUP, fixupTs, setCurrentMembership(bundle));
+    result.put(e.key(), e);
+
+    return result;
+  }
+
+  private PersonIdent getServerIdent(Map<Key, Collection<Event>> events) {
+    // Created with MultimapBuilder.treeKeys, so the keySet is navigable.
+    Key lastKey = ((NavigableSet<Key>) events.keySet()).last();
+    checkState(lastKey.type() == Type.FIXUP);
+    PersonIdent ident = serverIdent.get();
+    return new PersonIdent(
+        ident.getName(),
+        ident.getEmailAddress(),
+        Iterables.getOnlyElement(events.get(lastKey)).when(),
+        ident.getTimeZone());
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> addMember(Account.Id toAdd) {
+    return b -> {
+      MemberModification prev = b.getMemberModification();
+      b.setMemberModification(in -> Sets.union(prev.apply(in), ImmutableSet.of(toAdd)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> removeMember(Account.Id toRemove) {
+    return b -> {
+      MemberModification prev = b.getMemberModification();
+      b.setMemberModification(in -> Sets.difference(prev.apply(in), ImmutableSet.of(toRemove)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> addGroup(AccountGroup.UUID toAdd) {
+    return b -> {
+      SubgroupModification prev = b.getSubgroupModification();
+      b.setSubgroupModification(in -> Sets.union(prev.apply(in), ImmutableSet.of(toAdd)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> removeGroup(AccountGroup.UUID toRemove) {
+    return b -> {
+      SubgroupModification prev = b.getSubgroupModification();
+      b.setSubgroupModification(in -> Sets.difference(prev.apply(in), ImmutableSet.of(toRemove)));
+    };
+  }
+
+  private static Consumer<InternalGroupUpdate.Builder> setCurrentMembership(GroupBundle bundle) {
+    // Overwrite members and subgroups with the current values. The storage layer will do the
+    // set differences to compute the appropriate delta, if any.
+    return b ->
+        b.setMemberModification(
+                in ->
+                    bundle.members().stream().map(m -> m.getAccountId()).collect(toImmutableSet()))
+            .setSubgroupModification(
+                in ->
+                    bundle.byId().stream().map(m -> m.getIncludeUUID()).collect(toImmutableSet()));
+  }
+
+  private static Event event(
+      Type type,
+      Account.Id accountId,
+      Timestamp when,
+      Consumer<InternalGroupUpdate.Builder> update) {
+    return new AutoValue_GroupRebuilder_Event(type, Optional.of(accountId), when, update);
+  }
+
+  private static Event serverEvent(
+      Type type, Timestamp when, Consumer<InternalGroupUpdate.Builder> update) {
+    return new AutoValue_GroupRebuilder_Event(type, Optional.empty(), when, update);
+  }
+
+  @AutoValue
+  abstract static class Event {
+    abstract Type type();
+
+    abstract Optional<Account.Id> accountId();
+
+    abstract Timestamp when();
+
+    abstract Consumer<InternalGroupUpdate.Builder> update();
+
+    Key key() {
+      return new AutoValue_GroupRebuilder_Key(accountId(), when(), type());
+    }
+  }
+
+  /**
+   * Distinct event types.
+   *
+   * <p>Events at the same time by the same user are batched together by type. The types should
+   * correspond to the possible batch operations supported by {@link
+   * com.google.gerrit.server.audit.AuditService}.
+   */
+  enum Type {
+    ADD_MEMBER,
+    REMOVE_MEMBER,
+    ADD_GROUP,
+    REMOVE_GROUP,
+    FIXUP;
+  }
+
+  @AutoValue
+  abstract static class Key {
+    static final Comparator<Key> COMPARATOR =
+        Comparator.comparing(Key::when)
+            .thenComparing(
+                k -> k.accountId().map(Account.Id::get).orElse(null),
+                Comparator.nullsFirst(Comparator.naturalOrder()))
+            .thenComparing(Key::type);
+
+    abstract Optional<Account.Id> accountId();
+
+    abstract Timestamp when();
+
+    abstract Type type();
+  }
+}
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 2b1cb3d..ba60fbc 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -29,9 +30,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
 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.group.InternalGroup;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
@@ -42,10 +43,7 @@
 import java.util.Optional;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * A database accessor for read calls related to groups.
@@ -55,24 +53,28 @@
  * directly. There are a few exceptions though: schema classes, wrapper classes, and classes
  * executed during init. The latter ones should use {@code GroupsOnInit} instead.
  *
+ * <p>Most callers should not need to read groups directly from the database; they should use the
+ * {@link com.google.gerrit.server.account.GroupCache GroupCache} instead.
+ *
  * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
  */
 @Singleton
 public class Groups {
-  private static final Logger log = LoggerFactory.getLogger(Groups.class);
-
-  private final boolean readFromNoteDb;
+  private final GroupsMigration groupsMigration;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
+  private final AuditLogReader auditLogReader;
 
   @Inject
   public Groups(
-      @GerritServerConfig Config config,
+      GroupsMigration groupsMigration,
       GitRepositoryManager repoManager,
-      AllUsersName allUsersName) {
-    readFromNoteDb = config.getBoolean("user", null, "readGroupsFromNoteDb", false);
+      AllUsersName allUsersName,
+      AuditLogReader auditLogReader) {
+    this.groupsMigration = groupsMigration;
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
+    this.auditLogReader = auditLogReader;
   }
 
   /**
@@ -105,7 +107,7 @@
    */
   public Optional<InternalGroup> getGroup(ReviewDb db, AccountGroup.UUID groupUuid)
       throws OrmException, IOException, ConfigInvalidException {
-    if (readFromNoteDb) {
+    if (groupsMigration.readFromNoteDb()) {
       try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
         return getGroupFromNoteDb(allUsersRepo, groupUuid);
       }
@@ -125,7 +127,7 @@
     return groupConfig.getLoadedGroup();
   }
 
-  private static InternalGroup asInternalGroup(ReviewDb db, AccountGroup accountGroup)
+  public static InternalGroup asInternalGroup(ReviewDb db, AccountGroup accountGroup)
       throws OrmException {
     ImmutableSet<Account.Id> members =
         getMembersFromReviewDb(db, accountGroup.getId()).collect(toImmutableSet());
@@ -171,25 +173,25 @@
     }
   }
 
-  public Stream<InternalGroup> getAll(ReviewDb db) throws OrmException {
-    // TODO(aliceks): Add code for NoteDb.
-    return getAllUuids(db)
-        .map(groupUuid -> getGroupIfPossible(db, groupUuid))
-        .flatMap(Streams::stream);
-  }
-
-  public Stream<AccountGroup.UUID> getAllUuids(ReviewDb db) throws OrmException {
-    // TODO(aliceks): Add code for NoteDb.
-    return Streams.stream(db.accountGroups().all()).map(AccountGroup::getGroupUUID);
-  }
-
-  private Optional<InternalGroup> getGroupIfPossible(ReviewDb db, AccountGroup.UUID groupUuid) {
-    try {
-      return getGroup(db, groupUuid);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      log.warn(String.format("Cannot look up group %s by uuid", groupUuid.get()), e);
+  /**
+   * Returns {@code GroupReference}s for all internal groups.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @return a stream of the {@code GroupReference}s of all internal groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
+   */
+  public Stream<GroupReference> getAllGroupReferences(ReviewDb db)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+        return GroupNameNotes.loadAllGroupReferences(allUsersRepo).stream();
+      }
     }
-    return Optional.empty();
+
+    return Streams.stream(db.accountGroups().all())
+        .map(group -> new GroupReference(group.getGroupUUID(), group.getName()));
   }
 
   /**
@@ -273,33 +275,60 @@
    * @param db the {@code ReviewDb} instance to use for lookups
    * @return a stream of the UUIDs of the known external groups
    * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the data in NoteDb is in an incorrect format
    */
-  public Stream<AccountGroup.UUID> getExternalGroups(ReviewDb db) throws OrmException {
-    // TODO(aliceks): Add code for NoteDb.
+  public Stream<AccountGroup.UUID> getExternalGroups(ReviewDb db)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+        return getExternalGroupsFromNoteDb(allUsersRepo);
+      }
+    }
+
     return Streams.stream(db.accountGroupById().all())
         .map(AccountGroupById::getIncludeUUID)
         .distinct()
         .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
   }
 
+  private Stream<AccountGroup.UUID> getExternalGroupsFromNoteDb(Repository allUsersRepo)
+      throws IOException, ConfigInvalidException {
+    ImmutableSet<GroupReference> allInternalGroups =
+        GroupNameNotes.loadAllGroupReferences(allUsersRepo);
+    ImmutableSet.Builder<AccountGroup.UUID> allSubgroups = ImmutableSet.builder();
+    for (GroupReference internalGroup : allInternalGroups) {
+      Optional<InternalGroup> group = getGroupFromNoteDb(allUsersRepo, internalGroup.getUUID());
+      group.map(InternalGroup::getSubgroups).ifPresent(allSubgroups::addAll);
+    }
+    return allSubgroups
+        .build()
+        .stream()
+        .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
+  }
+
   /**
    * Returns the membership audit records for a given group.
    *
    * @param db the {@code ReviewDb} instance to use for lookups
+   * @param repo All-Users repository.
    * @param groupUuid the UUID of the group
    * @return the audit records, in arbitrary order; empty if the group does not exist
    * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
    */
-  public List<AccountGroupMemberAudit> getMembersAudit(ReviewDb db, AccountGroup.UUID groupUuid)
-      throws OrmException {
-    if (readFromNoteDb) {
-      // TODO(dborowitz): Implement.
-      throw new OrmException("Audit logs not yet implemented in NoteDb");
+  public List<AccountGroupMemberAudit> getMembersAudit(
+      ReviewDb db, Repository repo, AccountGroup.UUID groupUuid)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return auditLogReader.getMembersAudit(repo, groupUuid);
     }
     Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
     if (!group.isPresent()) {
       return ImmutableList.of();
     }
+
     return db.accountGroupMembersAudit().byGroup(group.get().getId()).toList();
   }
 
@@ -307,20 +336,24 @@
    * Returns the subgroup audit records for a given group.
    *
    * @param db the {@code ReviewDb} instance to use for lookups
+   * @param repo All-Users repository.
    * @param groupUuid the UUID of the group
    * @return the audit records, in arbitrary order; empty if the group does not exist
    * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws IOException if an error occurs while reading from NoteDb
+   * @throws ConfigInvalidException if the group couldn't be retrieved from NoteDb
    */
-  public List<AccountGroupByIdAud> getSubgroupsAudit(ReviewDb db, AccountGroup.UUID groupUuid)
-      throws OrmException {
-    if (readFromNoteDb) {
-      // TODO(dborowitz): Implement.
-      throw new OrmException("Audit logs not yet implemented in NoteDb");
+  public List<AccountGroupByIdAud> getSubgroupsAudit(
+      ReviewDb db, Repository repo, AccountGroup.UUID groupUuid)
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return auditLogReader.getSubgroupsAudit(repo, groupUuid);
     }
     Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
     if (!group.isPresent()) {
       return ImmutableList.of();
     }
+
     return db.accountGroupByIdAud().byGroup(group.get().getId()).toList();
   }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 81f3d8f..9e3eadb 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
@@ -43,10 +44,13 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.RenameGroupOp;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.gerrit.server.update.RefUpdateUtil;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -58,7 +62,9 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 
@@ -100,7 +106,9 @@
   @Nullable private final IdentifiedUser currentUser;
   private final PersonIdent authorIdent;
   private final MetaDataUpdateFactory metaDataUpdateFactory;
-  private final boolean writeGroupsToNoteDb;
+  private final GroupsMigration groupsMigration;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final boolean reviewDbUpdatesAreBlocked;
 
   @Inject
   GroupsUpdate(
@@ -114,9 +122,10 @@
       RenameGroupOp.Factory renameGroupOpFactory,
       @GerritServerId String serverId,
       @GerritPersonIdent PersonIdent serverIdent,
-      MetaDataUpdate.User metaDataUpdateUserFactory,
-      MetaDataUpdate.Server metaDataUpdateServerFactory,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GroupsMigration groupsMigration,
       @GerritServerConfig Config config,
+      GitReferenceUpdated gitRefUpdated,
       @Assisted @Nullable IdentifiedUser currentUser) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
@@ -127,44 +136,38 @@
     this.anonymousCowardName = anonymousCowardName;
     this.renameGroupOpFactory = renameGroupOpFactory;
     this.serverId = serverId;
+    this.groupsMigration = groupsMigration;
+    this.gitRefUpdated = gitRefUpdated;
     this.currentUser = currentUser;
     metaDataUpdateFactory =
         getMetaDataUpdateFactory(
-            metaDataUpdateUserFactory,
-            metaDataUpdateServerFactory,
-            currentUser,
-            serverIdent,
-            serverId,
-            anonymousCowardName);
+            metaDataUpdateInternalFactory, currentUser, serverIdent, serverId, anonymousCowardName);
     authorIdent = getAuthorIdent(serverIdent, currentUser);
-    // TODO(aliceks): Remove this flag when all other necessary TODOs for writing groups to NoteDb
-    // have been addressed.
-    // Don't flip this flag in a production setting! We only added it to spread the implementation
-    // of groups in NoteDb among several changes which are gradually merged.
-    writeGroupsToNoteDb = config.getBoolean("user", null, "writeGroupsToNoteDb", false);
+    reviewDbUpdatesAreBlocked = config.getBoolean("user", null, "blockReviewDbGroupUpdates", false);
   }
 
-  // TODO(aliceks): Introduce a common class for MetaDataUpdate.User and MetaDataUpdate.Server which
-  // doesn't require this ugly code. In addition, allow to pass in the repository and to use another
-  // author ident.
   private static MetaDataUpdateFactory getMetaDataUpdateFactory(
-      MetaDataUpdate.User metaDataUpdateUserFactory,
-      MetaDataUpdate.Server metaDataUpdateServerFactory,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
       @Nullable IdentifiedUser currentUser,
       PersonIdent serverIdent,
       String serverId,
       String anonymousCowardName) {
-    return currentUser != null
-        ? projectName -> {
-          MetaDataUpdate metaDataUpdate =
-              metaDataUpdateUserFactory.create(projectName, currentUser);
-          PersonIdent authorIdent =
-              getAuditLogAuthorIdent(
-                  currentUser.getAccount(), serverIdent, serverId, anonymousCowardName);
-          metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
-          return metaDataUpdate;
-        }
-        : metaDataUpdateServerFactory::create;
+    return (projectName, repository, batchRefUpdate) -> {
+      MetaDataUpdate metaDataUpdate =
+          metaDataUpdateInternalFactory.create(projectName, repository, batchRefUpdate);
+      metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+      PersonIdent authorIdent;
+      if (currentUser != null) {
+        metaDataUpdate.setAuthor(currentUser);
+        authorIdent =
+            getAuditLogAuthorIdent(
+                currentUser.getAccount(), serverIdent, serverId, anonymousCowardName);
+      } else {
+        authorIdent = serverIdent;
+      }
+      metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
+      return metaDataUpdate;
+    };
   }
 
   private static PersonIdent getAuditLogAuthorIdent(
@@ -202,13 +205,17 @@
   public InternalGroup createGroup(
       ReviewDb db, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
       throws OrmException, IOException, ConfigInvalidException {
-    InternalGroup createdGroupInReviewDb = createGroupInReviewDb(db, groupCreation, groupUpdate);
+    if (!groupsMigration.disableGroupReviewDb()) {
+      InternalGroup createdGroupInReviewDb =
+          createGroupInReviewDb(ReviewDbUtil.unwrapDb(db), groupCreation, groupUpdate);
 
-    if (!writeGroupsToNoteDb) {
-      updateCachesOnGroupCreation(createdGroupInReviewDb);
-      return createdGroupInReviewDb;
+      if (!groupsMigration.writeToNoteDb()) {
+        updateCachesOnGroupCreation(createdGroupInReviewDb);
+        return createdGroupInReviewDb;
+      }
     }
 
+    // TODO(aliceks): Add retry mechanism.
     InternalGroup createdGroup = createGroupInNoteDb(groupCreation, groupUpdate);
     updateCachesOnGroupCreation(createdGroup);
     return createdGroup;
@@ -237,13 +244,17 @@
   public UpdateResult updateGroupInDb(
       ReviewDb db, AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
       throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
-    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
-    UpdateResult reviewDbUpdateResult = updateGroupInReviewDb(db, group, groupUpdate);
+    UpdateResult reviewDbUpdateResult = null;
+    if (!groupsMigration.disableGroupReviewDb()) {
+      AccountGroup group = getExistingGroupFromReviewDb(ReviewDbUtil.unwrapDb(db), groupUuid);
+      reviewDbUpdateResult = updateGroupInReviewDb(ReviewDbUtil.unwrapDb(db), group, groupUpdate);
 
-    if (!writeGroupsToNoteDb) {
-      return reviewDbUpdateResult;
+      if (!groupsMigration.writeToNoteDb()) {
+        return reviewDbUpdateResult;
+      }
     }
 
+    // TODO(aliceks): Add retry mechanism.
     Optional<UpdateResult> noteDbUpdateResult = updateGroupInNoteDb(groupUuid, groupUpdate);
     return noteDbUpdateResult.orElse(reviewDbUpdateResult);
   }
@@ -251,6 +262,7 @@
   private InternalGroup createGroupInReviewDb(
       ReviewDb db, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
       throws OrmException {
+    checkIfReviewDbUpdatesAreBlocked();
 
     AccountGroupName gn = new AccountGroupName(groupCreation.getNameKey(), groupCreation.getId());
     // first insert the group name to validate that the group name hasn't
@@ -260,7 +272,10 @@
     AccountGroup group = createAccountGroup(groupCreation);
     UpdateResult updateResult = updateGroupInReviewDb(db, group, groupUpdate);
     return InternalGroup.create(
-        group, updateResult.getModifiedMembers(), updateResult.getModifiedSubgroups());
+        group,
+        updateResult.getModifiedMembers(),
+        updateResult.getModifiedSubgroups(),
+        updateResult.getRefState());
   }
 
   public static AccountGroup createAccountGroup(
@@ -287,6 +302,8 @@
 
   private UpdateResult updateGroupInReviewDb(
       ReviewDb db, AccountGroup group, InternalGroupUpdate groupUpdate) throws OrmException {
+    checkIfReviewDbUpdatesAreBlocked();
+
     AccountGroup.NameKey originalName = group.getNameKey();
     applyUpdate(group, groupUpdate);
     AccountGroup.NameKey updatedName = group.getNameKey();
@@ -440,10 +457,14 @@
       InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
       throws IOException, ConfigInvalidException, OrmException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+      GroupNameNotes groupNameNotes =
+          GroupNameNotes.loadForNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+
       GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
-      // TODO(aliceks): Find a way to ensure unique names with NoteDb.
       groupConfig.setGroupUpdate(groupUpdate, this::getAccountNameEmail, this::getGroupName);
-      commit(groupConfig);
+
+      commit(allUsersRepo, groupConfig, groupNameNotes);
 
       return groupConfig
           .getLoadedGroup()
@@ -454,19 +475,27 @@
 
   private Optional<UpdateResult> updateGroupInNoteDb(
       AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
-      throws IOException, ConfigInvalidException {
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException, NoSuchGroupException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, groupUuid);
+      groupConfig.setGroupUpdate(groupUpdate, this::getAccountNameEmail, this::getGroupName);
       if (!groupConfig.getLoadedGroup().isPresent()) {
-        // TODO(aliceks): Throw a NoSuchGroupException here when all groups are stored in NoteDb.
+        if (groupsMigration.readFromNoteDb()) {
+          throw new NoSuchGroupException(groupUuid);
+        }
         return Optional.empty();
       }
 
       InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
 
-      // TODO(aliceks): Find a way to ensure unique names with NoteDb.
-      groupConfig.setGroupUpdate(groupUpdate, this::getAccountNameEmail, this::getGroupName);
-      commit(groupConfig);
+      GroupNameNotes groupNameNotes = null;
+      if (groupUpdate.getName().isPresent()) {
+        AccountGroup.NameKey oldName = originalGroup.getNameKey();
+        AccountGroup.NameKey newName = groupUpdate.getName().get();
+        groupNameNotes = GroupNameNotes.loadForRename(allUsersRepo, groupUuid, oldName, newName);
+      }
+
+      commit(allUsersRepo, groupConfig, groupNameNotes);
 
       InternalGroup updatedGroup =
           groupConfig
@@ -490,28 +519,41 @@
             .setGroupId(updatedGroup.getId())
             .setGroupName(updatedGroup.getNameKey())
             .setModifiedMembers(modifiedMembers)
-            .setModifiedSubgroups(modifiedSubgroups);
+            .setModifiedSubgroups(modifiedSubgroups)
+            .setRefState(updatedGroup.getRefState());
     if (!Objects.equals(originalGroup.getNameKey(), updatedGroup.getNameKey())) {
       resultBuilder.setPreviousGroupName(originalGroup.getNameKey());
     }
     return resultBuilder.build();
   }
 
-  private String getAccountNameEmail(Account.Id accountId) {
+  static String getAccountName(
+      AccountCache accountCache, String anonymousCowardName, Account.Id accountId) {
     AccountState accountState = accountCache.getOrNull(accountId);
-    String accountName =
-        Optional.ofNullable(accountState)
-            .map(AccountState::getAccount)
-            .map(account -> account.getName(anonymousCowardName))
-            .orElse(anonymousCowardName);
+    return Optional.ofNullable(accountState)
+        .map(AccountState::getAccount)
+        .map(account -> account.getName(anonymousCowardName))
+        .orElse(anonymousCowardName);
+  }
+
+  static String getAccountNameEmail(
+      AccountCache accountCache,
+      String anonymousCowardName,
+      Account.Id accountId,
+      String serverId) {
+    String accountName = getAccountName(accountCache, anonymousCowardName, accountId);
     return formatNameEmail(accountName, getEmailForAuditLog(accountId, serverId));
   }
 
-  private static String getEmailForAuditLog(Account.Id accountId, String serverId) {
+  static String getEmailForAuditLog(Account.Id accountId, String serverId) {
     return accountId.get() + "@" + serverId;
   }
 
-  private String getGroupName(AccountGroup.UUID groupUuid) {
+  private String getAccountNameEmail(Account.Id accountId) {
+    return getAccountNameEmail(accountCache, anonymousCowardName, accountId, serverId);
+  }
+
+  static String getGroupName(GroupCache groupCache, AccountGroup.UUID groupUuid) {
     return groupCache
         .get(groupUuid)
         .map(InternalGroup::getName)
@@ -519,6 +561,10 @@
         .orElse(groupUuid.get());
   }
 
+  private String getGroupName(AccountGroup.UUID groupUuid) {
+    return getGroupName(groupCache, groupUuid);
+  }
+
   private static String formatNameEmail(String name, String email) {
     StringBuilder formattedResult = new StringBuilder();
     PersonIdent.appendSanitized(formattedResult, name);
@@ -528,10 +574,25 @@
     return formattedResult.toString();
   }
 
-  private void commit(GroupConfig groupConfig) throws IOException {
-    try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(allUsersName)) {
+  private void commit(
+      Repository allUsersRepo, GroupConfig groupConfig, @Nullable GroupNameNotes groupNameNotes)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate metaDataUpdate =
+        metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
       groupConfig.commit(metaDataUpdate);
     }
+    if (groupNameNotes != null) {
+      // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+      try (MetaDataUpdate metaDataUpdate =
+          metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) {
+        groupNameNotes.commit(metaDataUpdate);
+      }
+    }
+
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+    gitRefUpdated.fire(
+        allUsersName, batchRefUpdate, currentUser != null ? currentUser.getAccount() : null);
   }
 
   private void updateCachesOnGroupCreation(InternalGroup createdGroup) throws IOException {
@@ -569,9 +630,17 @@
     }
   }
 
+  private void checkIfReviewDbUpdatesAreBlocked() throws OrmException {
+    if (reviewDbUpdatesAreBlocked) {
+      throw new OrmException("Updates to groups in ReviewDb are blocked");
+    }
+  }
+
   @FunctionalInterface
   private interface MetaDataUpdateFactory {
-    MetaDataUpdate create(Project.NameKey projectName) throws IOException;
+    MetaDataUpdate create(
+        Project.NameKey projectName, Repository repository, BatchRefUpdate batchRefUpdate)
+        throws IOException;
   }
 
   @AutoValue
@@ -588,6 +657,9 @@
 
     abstract ImmutableSet<AccountGroup.UUID> getModifiedSubgroups();
 
+    @Nullable
+    public abstract ObjectId getRefState();
+
     static Builder builder() {
       return new AutoValue_GroupsUpdate_UpdateResult.Builder();
     }
@@ -606,6 +678,8 @@
 
       abstract Builder setModifiedSubgroups(Set<AccountGroup.UUID> modifiedSubgroups);
 
+      public abstract Builder setRefState(ObjectId refState);
+
       abstract UpdateResult build();
     }
   }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
index f9fe4bb..13f84c1 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
@@ -65,8 +65,12 @@
 
     public abstract Builder setMemberModification(MemberModification memberModification);
 
+    abstract MemberModification getMemberModification();
+
     public abstract Builder setSubgroupModification(SubgroupModification subgroupModification);
 
+    abstract SubgroupModification getSubgroupModification();
+
     public abstract InternalGroupUpdate build();
   }
 }
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
new file mode 100644
index 0000000..1a5b598
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -0,0 +1,15 @@
+package(default_visibility = ["//visibility:public"])
+
+java_library(
+    name = "testing",
+    testonly = 1,
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
new file mode 100644
index 0000000..a71f417
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
@@ -0,0 +1,76 @@
+// 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.group.db.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.group.db.GroupNameNotes.getGroupReference;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.CommitUtil;
+import java.io.IOException;
+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.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Test utilities for low-level NoteDb groups. */
+public class GroupTestUtil {
+  public static ImmutableMap<String, String> readNameToUuidMap(Repository repo) throws Exception {
+    ImmutableMap.Builder<String, String> result = ImmutableMap.builder();
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.REFS_GROUPNAMES);
+      if (ref != null) {
+        NoteMap noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(ref.getObjectId()));
+        for (Note note : noteMap) {
+          GroupReference gr = getGroupReference(rw.getObjectReader(), note.getData());
+          result.put(gr.getName(), gr.getUUID().get());
+        }
+      }
+    }
+    return result.build();
+  }
+
+  // TODO(dborowitz): Move somewhere even more common.
+  public static ImmutableList<CommitInfo> log(Repository repo, String refName) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(refName);
+      if (ref != null) {
+        rw.sort(RevSort.REVERSE);
+        rw.markStart(rw.parseCommit(ref.getObjectId()));
+        return Streams.stream(rw)
+            .map(
+                c -> {
+                  try {
+                    return CommitUtil.toCommitInfo(c);
+                  } catch (IOException e) {
+                    throw new IllegalStateException(
+                        "unexpected state when converting commit " + c.getName(), e);
+                  }
+                })
+            .collect(toImmutableList());
+      }
+    }
+    return ImmutableList.of();
+  }
+
+  private GroupTestUtil() {}
+}
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index b37ed61..2abe876 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
@@ -30,12 +31,27 @@
 import com.google.gerrit.server.query.change.SingleGroupUser;
 import java.io.IOException;
 import java.util.Set;
+import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public final class IndexUtils {
   public static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
       ImmutableMap.of("_", " ", ".", " ");
 
+  public static final Function<Exception, IOException> MAPPER =
+      new Function<Exception, IOException>() {
+        @Override
+        public IOException apply(Exception in) {
+          if (in instanceof IOException) {
+            return (IOException) in;
+          } else if (in instanceof ExecutionException && in.getCause() instanceof IOException) {
+            return (IOException) in.getCause();
+          } else {
+            return new IOException(in);
+          }
+        }
+      };
+
   public static void setReady(SitePaths sitePaths, String name, int version, boolean ready)
       throws IOException {
     try {
@@ -57,10 +73,13 @@
   }
 
   public static Set<String> accountFields(QueryOptions opts) {
-    Set<String> fs = opts.fields();
-    return fs.contains(AccountField.ID.getName())
-        ? fs
-        : Sets.union(fs, ImmutableSet.of(AccountField.ID.getName()));
+    return accountFields(opts.fields());
+  }
+
+  public static Set<String> accountFields(Set<String> fields) {
+    return fields.contains(AccountField.ID.getName())
+        ? fields
+        : Sets.union(fields, ImmutableSet.of(AccountField.ID.getName()));
   }
 
   public static Set<String> changeFields(QueryOptions opts) {
diff --git a/java/com/google/gerrit/server/index/RefState.java b/java/com/google/gerrit/server/index/RefState.java
index b55afb6..6b893f0 100644
--- a/java/com/google/gerrit/server/index/RefState.java
+++ b/java/com/google/gerrit/server/index/RefState.java
@@ -19,9 +19,13 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
+import java.util.List;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -29,6 +33,20 @@
 
 @AutoValue
 public abstract class RefState {
+  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
+    RefState.check(states != null, null);
+    SetMultimap<Project.NameKey, RefState> result =
+        MultimapBuilder.hashKeys().hashSetValues().build();
+    for (byte[] b : states) {
+      RefState.check(b != null, null);
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
+      result.put(new Project.NameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
+    }
+    return result;
+  }
+
   public static RefState create(String ref, String sha) {
     return new AutoValue_RefState(ref, ObjectId.fromString(sha));
   }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index 6ec1260..b0527e1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -14,7 +14,11 @@
 
 package com.google.gerrit.server.index.account;
 
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -22,11 +26,17 @@
 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.GerritServerConfig;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
 
 public class AccountIndexerImpl implements AccountIndexer {
   public interface Factory {
@@ -37,6 +47,9 @@
 
   private final AccountCache byIdCache;
   private final DynamicSet<AccountIndexedListener> indexedListener;
+  private final StalenessChecker stalenessChecker;
+  private final ListeningExecutorService batchExecutor;
+  private final boolean autoReindexIfStale;
   private final AccountIndexCollection indexes;
   private final AccountIndex index;
 
@@ -44,9 +57,15 @@
   AccountIndexerImpl(
       AccountCache byIdCache,
       DynamicSet<AccountIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @GerritServerConfig Config config,
       @Assisted AccountIndexCollection indexes) {
     this.byIdCache = byIdCache;
     this.indexedListener = indexedListener;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(config);
     this.indexes = indexes;
     this.index = null;
   }
@@ -55,9 +74,15 @@
   AccountIndexerImpl(
       AccountCache byIdCache,
       DynamicSet<AccountIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
+      @GerritServerConfig Config config,
       @Assisted AccountIndex index) {
     this.byIdCache = byIdCache;
     this.indexedListener = indexedListener;
+    this.stalenessChecker = stalenessChecker;
+    this.batchExecutor = batchExecutor;
+    this.autoReindexIfStale = autoReindexIfStale(config);
     this.indexes = null;
     this.index = index;
   }
@@ -73,6 +98,44 @@
       }
     }
     fireAccountIndexedEvent(id.get());
+    autoReindexIfStale(id);
+  }
+
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "autoReindexIfStale", false);
+  }
+
+  private void autoReindexIfStale(Account.Id id) {
+    if (autoReindexIfStale) {
+      // Don't retry indefinitely; if this fails the account will be stale.
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = reindexIfStale(id);
+    }
+  }
+
+  /**
+   * Asynchronously check if a account is stale, and reindex if it is.
+   *
+   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
+   * different executor.
+   *
+   * @param id the ID of the account.
+   * @return future for reindexing the account; returns true if the account was stale.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
+      Account.Id id) {
+    Callable<Boolean> task =
+        () -> {
+          if (stalenessChecker.isStale(id)) {
+            index(id);
+            return true;
+          }
+          return false;
+        };
+
+    return Futures.makeChecked(
+        Futures.nonCancellationPropagating(batchExecutor.submit(task)), IndexUtils.MAPPER);
   }
 
   private void fireAccountIndexedEvent(int id) {
diff --git a/java/com/google/gerrit/server/index/account/StalenessChecker.java b/java/com/google/gerrit/server/index/account/StalenessChecker.java
new file mode 100644
index 0000000..6403d3d
--- /dev/null
+++ b/java/com/google/gerrit/server/index/account/StalenessChecker.java
@@ -0,0 +1,151 @@
+// 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.index.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.RefState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Checks if documents in the account index are stale.
+ *
+ * <p>An index document is considered stale if the stored ref state differs from the SHA1 of the
+ * user branch or if the stored external ID states don't match with the external IDs of the account
+ * from the refs/meta/external-ids branch.
+ */
+@Singleton
+public class StalenessChecker {
+  public static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(
+          AccountField.ID.getName(),
+          AccountField.REF_STATE.getName(),
+          AccountField.EXTERNAL_ID_STATE.getName());
+
+  private final AccountIndexCollection indexes;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+  private final IndexConfig indexConfig;
+
+  @Inject
+  StalenessChecker(
+      AccountIndexCollection indexes,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      ExternalIds externalIds,
+      IndexConfig indexConfig) {
+    this.indexes = indexes;
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.externalIds = externalIds;
+    this.indexConfig = indexConfig;
+  }
+
+  public boolean isStale(Account.Id id) throws IOException {
+    AccountIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      // No index; caller couldn't do anything if it is stale.
+      return false;
+    }
+    if (!i.getSchema().hasField(AccountField.REF_STATE)
+        || !i.getSchema().hasField(AccountField.EXTERNAL_ID_STATE)) {
+      // Index version not new enough for this check.
+      return false;
+    }
+
+    Optional<FieldBundle> result =
+        i.getRaw(id, QueryOptions.create(indexConfig, 0, 1, IndexUtils.accountFields(FIELDS)));
+    if (!result.isPresent()) {
+      // The document is missing in the index.
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        Ref ref = repo.exactRef(RefNames.refsUsers(id));
+
+        // Stale if the account actually exists.
+        return ref != null;
+      }
+    }
+
+    for (Map.Entry<Project.NameKey, RefState> e :
+        RefState.parseStates(result.get().getValue(AccountField.REF_STATE)).entries()) {
+      try (Repository repo = repoManager.openRepository(e.getKey())) {
+        if (!e.getValue().match(repo)) {
+          // Ref was modified since the account was indexed.
+          return true;
+        }
+      }
+    }
+
+    Set<ExternalId> extIds = externalIds.byAccount(id);
+    ListMultimap<ObjectId, ObjectId> extIdStates =
+        parseExternalIdStates(result.get().getValue(AccountField.EXTERNAL_ID_STATE));
+    if (extIdStates.size() != extIds.size()) {
+      // External IDs of the account were modified since the account was indexed.
+      return true;
+    }
+    for (ExternalId extId : extIds) {
+      if (!extIdStates.containsEntry(extId.key().sha1(), extId.blobId())) {
+        // External IDs of the account were modified since the account was indexed.
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  public static ListMultimap<ObjectId, ObjectId> parseExternalIdStates(
+      Iterable<byte[]> extIdStates) {
+    ListMultimap<ObjectId, ObjectId> result = MultimapBuilder.hashKeys().arrayListValues().build();
+
+    if (extIdStates == null) {
+      return result;
+    }
+
+    for (byte[] b : extIdStates) {
+      checkNotNull(b, "invalid external ID state");
+      String s = new String(b, UTF_8);
+      List<String> parts = Splitter.on(':').splitToList(s);
+      checkState(parts.size() == 2, "invalid external ID state: %s", s);
+      result.put(ObjectId.fromString(parts.get(0)), ObjectId.fromString(parts.get(1)));
+    }
+    return result;
+  }
+}
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 37dd4a4..2444735 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -563,10 +563,10 @@
               });
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
-      SubmitRuleOptions.defaults().allowClosed(true).build();
+      SubmitRuleOptions.builder().allowClosed(true).build();
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
-      SubmitRuleOptions.defaults().build();
+      SubmitRuleOptions.builder().build();
 
   /**
    * JSON type for storing SubmitRecords.
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index f62b662..d205e91 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.server.extensions.events.EventUtil.logEventListenerError;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
-import com.google.common.base.Function;
 import com.google.common.util.concurrent.Atomics;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -31,6 +30,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -50,7 +50,6 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.lib.Config;
@@ -79,23 +78,9 @@
     // ExecutionException, so we can reuse the same mapper as for a single
     // future. Assume the actual contents of the exception are not useful to
     // callers. All exceptions are already logged by IndexTask.
-    return Futures.makeChecked(Futures.allAsList(futures), MAPPER);
+    return Futures.makeChecked(Futures.allAsList(futures), IndexUtils.MAPPER);
   }
 
-  private static final Function<Exception, IOException> MAPPER =
-      new Function<Exception, IOException>() {
-        @Override
-        public IOException apply(Exception in) {
-          if (in instanceof IOException) {
-            return (IOException) in;
-          } else if (in instanceof ExecutionException && in.getCause() instanceof IOException) {
-            return (IOException) in.getCause();
-          } else {
-            return new IOException(in);
-          }
-        }
-      };
-
   private final ChangeIndexCollection indexes;
   private final ChangeIndex index;
   private final SchemaFactory<ReviewDb> schemaFactory;
@@ -335,7 +320,8 @@
   @SuppressWarnings("deprecation")
   private static <T> com.google.common.util.concurrent.CheckedFuture<T, IOException> submit(
       Callable<T> task, ListeningExecutorService executor) {
-    return Futures.makeChecked(Futures.nonCancellationPropagating(executor.submit(task)), MAPPER);
+    return Futures.makeChecked(
+        Futures.nonCancellationPropagating(executor.submit(task)), IndexUtils.MAPPER);
   }
 
   private abstract class AbstractIndexTask<T> implements Callable<T> {
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index e804702..e7790df 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -158,21 +158,7 @@
   }
 
   private SetMultimap<Project.NameKey, RefState> parseStates(ChangeData cd) {
-    return parseStates(cd.getRefStates());
-  }
-
-  public static SetMultimap<Project.NameKey, RefState> parseStates(Iterable<byte[]> states) {
-    RefState.check(states != null, null);
-    SetMultimap<Project.NameKey, RefState> result =
-        MultimapBuilder.hashKeys().hashSetValues().build();
-    for (byte[] b : states) {
-      RefState.check(b != null, null);
-      String s = new String(b, UTF_8);
-      List<String> parts = Splitter.on(':').splitToList(s);
-      RefState.check(parts.size() == 3 && !parts.get(0).isEmpty() && !parts.get(1).isEmpty(), s);
-      result.put(new Project.NameKey(parts.get(0)), RefState.create(parts.get(1), parts.get(2)));
-    }
-    return result;
+    return RefState.parseStates(cd.getRefStates());
   }
 
   private ListMultimap<Project.NameKey, RefStatePattern> parsePatterns(ChangeData cd) {
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 2ca0982..d10d70a 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -21,6 +21,7 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -32,6 +33,7 @@
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
@@ -39,6 +41,7 @@
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ProgressMonitor;
 import org.eclipse.jgit.lib.TextProgressMonitor;
 import org.slf4j.Logger;
@@ -73,7 +76,7 @@
     List<AccountGroup.UUID> uuids;
     try {
       uuids = collectGroups(progress);
-    } catch (OrmException e) {
+    } catch (OrmException | IOException | ConfigInvalidException e) {
       log.error("Error collecting groups", e);
       return new SiteIndexer.Result(sw, false, 0, 0);
     }
@@ -128,10 +131,14 @@
     return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
   }
 
-  private List<AccountGroup.UUID> collectGroups(ProgressMonitor progress) throws OrmException {
+  private List<AccountGroup.UUID> collectGroups(ProgressMonitor progress)
+      throws OrmException, IOException, ConfigInvalidException {
     progress.beginTask("Collecting groups", ProgressMonitor.UNKNOWN);
     try (ReviewDb db = schemaFactory.open()) {
-      return groups.getAllUuids(db).collect(toImmutableList());
+      return groups
+          .getAllGroupReferences(db)
+          .map(GroupReference::getUUID)
+          .collect(toImmutableList());
     } finally {
       progress.endTask();
     }
diff --git a/java/com/google/gerrit/server/index/group/GroupField.java b/java/com/google/gerrit/server/index/group/GroupField.java
index 078433a..29e3867 100644
--- a/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/java/com/google/gerrit/server/index/group/GroupField.java
@@ -19,14 +19,18 @@
 import static com.google.gerrit.index.FieldDef.fullText;
 import static com.google.gerrit.index.FieldDef.integer;
 import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
 import static com.google.gerrit.index.FieldDef.timestamp;
 
+import com.google.common.base.MoreObjects;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
 import java.sql.Timestamp;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for groups. */
 public class GroupField {
@@ -72,4 +76,14 @@
           .buildRepeatable(
               g ->
                   g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
+
+  /** ObjectId of HEAD:refs/groups/<UUID>. */
+  public static final FieldDef<InternalGroup, byte[]> REF_STATE =
+      storedOnly("ref_state")
+          .build(
+              g -> {
+                byte[] a = new byte[Constants.OBJECT_ID_STRING_LENGTH];
+                MoreObjects.firstNonNull(g.getRefState(), ObjectId.zeroId()).copyTo(a, 0);
+                return a;
+              });
 }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index 69b29bc..58caeb2 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -14,20 +14,30 @@
 
 package com.google.gerrit.server.index.group;
 
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
 import com.google.common.collect.ImmutableSet;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.index.Index;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
 
 public class GroupIndexerImpl implements GroupIndexer {
   public interface Factory {
@@ -39,16 +49,25 @@
   private final GroupCache groupCache;
   private final DynamicSet<GroupIndexedListener> indexedListener;
   private final GroupIndexCollection indexes;
+  private final StalenessChecker stalenessChecker;
+  private final boolean autoReindexIfStale;
   private final GroupIndex index;
+  private final ListeningExecutorService batchExecutor;
 
   @AssistedInject
   GroupIndexerImpl(
       GroupCache groupCache,
       DynamicSet<GroupIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @GerritServerConfig Config config,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted GroupIndexCollection indexes) {
     this.groupCache = groupCache;
     this.indexedListener = indexedListener;
     this.indexes = indexes;
+    this.stalenessChecker = stalenessChecker;
+    this.autoReindexIfStale = autoReindexIfStale(config);
+    this.batchExecutor = batchExecutor;
     this.index = null;
   }
 
@@ -56,10 +75,16 @@
   GroupIndexerImpl(
       GroupCache groupCache,
       DynamicSet<GroupIndexedListener> indexedListener,
+      StalenessChecker stalenessChecker,
+      @GerritServerConfig Config config,
+      @IndexExecutor(BATCH) ListeningExecutorService batchExecutor,
       @Assisted GroupIndex index) {
     this.groupCache = groupCache;
     this.indexedListener = indexedListener;
     this.indexes = null;
+    this.stalenessChecker = stalenessChecker;
+    this.autoReindexIfStale = autoReindexIfStale(config);
+    this.batchExecutor = batchExecutor;
     this.index = index;
   }
 
@@ -74,6 +99,44 @@
       }
     }
     fireGroupIndexedEvent(uuid.get());
+    autoReindexIfStale(uuid);
+  }
+
+  private static boolean autoReindexIfStale(Config cfg) {
+    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
+  }
+
+  private void autoReindexIfStale(AccountGroup.UUID uuid) {
+    if (autoReindexIfStale) {
+      // Don't retry indefinitely; if this fails the group will be stale.
+      @SuppressWarnings("unused")
+      Future<?> possiblyIgnoredError = reindexIfStale(uuid);
+    }
+  }
+
+  /**
+   * Asynchronously check if a group is stale, and reindex if it is.
+   *
+   * <p>Always run on the batch executor, even if this indexer instance is configured to use a
+   * different executor.
+   *
+   * @param uuid the unique identifier of the group.
+   * @return future for reindexing the group; returns true if the group was stale.
+   */
+  @SuppressWarnings("deprecation")
+  public com.google.common.util.concurrent.CheckedFuture<Boolean, IOException> reindexIfStale(
+      AccountGroup.UUID uuid) {
+    Callable<Boolean> task =
+        () -> {
+          if (stalenessChecker.isStale(uuid)) {
+            index(uuid);
+            return true;
+          }
+          return false;
+        };
+
+    return Futures.makeChecked(
+        Futures.nonCancellationPropagating(batchExecutor.submit(task)), IndexUtils.MAPPER);
   }
 
   private void fireGroupIndexedEvent(String uuid) {
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index b280b25..912524f 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -34,8 +34,11 @@
 
   @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
 
+  @Deprecated
   static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
 
+  static final Schema<InternalGroup> V5 = schema(V4, GroupField.REF_STATE);
+
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
 
   private GroupSchemaDefinitions() {
diff --git a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 255df32..79f25c0 100644
--- a/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.IndexedQuery;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.query.DataSource;
@@ -22,10 +23,22 @@
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.group.InternalGroup;
+import java.util.HashSet;
+import java.util.Set;
 
 public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, InternalGroup>
     implements DataSource<InternalGroup> {
 
+  public static QueryOptions createOptions(
+      IndexConfig config, int start, int limit, Set<String> fields) {
+    // Always include GroupField.UUID since it is needed to load the group from NoteDb.
+    if (!fields.contains(GroupField.UUID.getName())) {
+      fields = new HashSet<>(fields);
+      fields.add(GroupField.UUID.getName());
+    }
+    return QueryOptions.create(config, start, limit, fields);
+  }
+
   public IndexedGroupQuery(
       Index<AccountGroup.UUID, InternalGroup> index,
       Predicate<InternalGroup> pred,
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
new file mode 100644
index 0000000..418bb35
--- /dev/null
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -0,0 +1,97 @@
+// 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.index.group;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.GroupsMigration;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Checks if documents in the group index are stale.
+ *
+ * <p>An index document is considered stale if the stored SHA1 differs from the HEAD SHA1 of the
+ * groups branch.
+ *
+ * <p>Note: This only applies to NoteDb.
+ */
+@Singleton
+public class StalenessChecker {
+  public static final ImmutableSet<String> FIELDS =
+      ImmutableSet.of(GroupField.UUID.getName(), GroupField.REF_STATE.getName());
+
+  private final GroupIndexCollection indexes;
+  private final GitRepositoryManager repoManager;
+  private final IndexConfig indexConfig;
+  private final AllUsersName allUsers;
+  private final GroupsMigration groupsMigration;
+
+  @Inject
+  StalenessChecker(
+      GroupIndexCollection indexes,
+      GitRepositoryManager repoManager,
+      IndexConfig indexConfig,
+      AllUsersName allUsers,
+      GroupsMigration groupsMigration) {
+    this.indexes = indexes;
+    this.repoManager = repoManager;
+    this.indexConfig = indexConfig;
+    this.allUsers = allUsers;
+    this.groupsMigration = groupsMigration;
+  }
+
+  public boolean isStale(AccountGroup.UUID uuid) throws IOException {
+    if (!groupsMigration.readFromNoteDb()) {
+      return false; // This class only treats staleness for groups in NoteDb.
+    }
+
+    GroupIndex i = indexes.getSearchIndex();
+    if (i == null) {
+      return false; // No index; caller couldn't do anything if it is stale.
+    }
+    if (!i.getSchema().hasField(GroupField.REF_STATE)) {
+      return false; // Index version not new enough for this check.
+    }
+
+    Optional<FieldBundle> result =
+        i.getRaw(uuid, IndexedGroupQuery.createOptions(indexConfig, 0, 1, FIELDS));
+    if (!result.isPresent()) {
+      // The document is missing in the index.
+      try (Repository repo = repoManager.openRepository(allUsers)) {
+        Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
+
+        // Stale if the group actually exists.
+        return ref != null;
+      }
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
+      ObjectId head = ref == null ? ObjectId.zeroId() : ref.getObjectId();
+      return !head.equals(ObjectId.fromString(result.get().getValue(GroupField.REF_STATE), 0));
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeBundle.java b/java/com/google/gerrit/server/notedb/ChangeBundle.java
index a9663c7..7714c6e 100644
--- a/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -17,8 +17,8 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.common.TimeUtil.roundToSecond;
+import static com.google.gerrit.common.TimeUtil.truncateToSecond;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.checkColumns;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
@@ -71,7 +71,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
-import java.util.TreeSet;
 
 /**
  * A bundle of all entities rooted at a single {@link Change} entity.
@@ -212,23 +211,6 @@
         .compare(a.get(), b.get());
   }
 
-  private static void checkColumns(Class<?> clazz, Integer... expected) {
-    Set<Integer> ids = new TreeSet<>();
-    for (Field f : clazz.getDeclaredFields()) {
-      Column col = f.getAnnotation(Column.class);
-      if (col != null) {
-        ids.add(col.id());
-      }
-    }
-    Set<Integer> expectedIds = Sets.newTreeSet(Arrays.asList(expected));
-    checkState(
-        ids.equals(expectedIds),
-        "Unexpected column set for %s: %s != %s",
-        clazz.getSimpleName(),
-        ids,
-        expectedIds);
-  }
-
   static {
     // Initialization-time checks that the column set hasn't changed since the
     // last time this file was updated.
@@ -958,7 +940,7 @@
     // seconds apart, the timestamp in NoteDb may actually be several seconds
     // *earlier* than the timestamp in ReviewDb that it was converted from.
     checkArgument(
-        tsFromNoteDb.equals(roundToSecond(tsFromNoteDb)),
+        tsFromNoteDb.equals(truncateToSecond(tsFromNoteDb)),
         "%s from NoteDb has non-rounded %s timestamp: %s",
         desc,
         field,
diff --git a/java/com/google/gerrit/server/notedb/GroupsMigration.java b/java/com/google/gerrit/server/notedb/GroupsMigration.java
new file mode 100644
index 0000000..293f3c6
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/GroupsMigration.java
@@ -0,0 +1,82 @@
+// 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.NoteDbTable.GROUPS;
+import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.READ;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.WRITE;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class GroupsMigration {
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(GroupsMigration.class);
+    }
+  }
+
+  private final boolean writeToNoteDb;
+  private final boolean readFromNoteDb;
+  private final boolean disableGroupReviewDb;
+
+  @Inject
+  public GroupsMigration(@GerritServerConfig Config cfg) {
+    // TODO(aliceks): Remove these flags when all other necessary TODOs for writing groups to
+    // NoteDb have been addressed.
+    // Don't flip these flags in a production setting! We only added them to spread the
+    // implementation of groups in NoteDb among several changes which are gradually merged.
+    this(
+        cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, false),
+        cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, false),
+        cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, false));
+  }
+
+  public GroupsMigration(
+      boolean writeToNoteDb, boolean readFromNoteDb, boolean disableGroupReviewDb) {
+    this.writeToNoteDb = writeToNoteDb;
+    this.readFromNoteDb = readFromNoteDb;
+    this.disableGroupReviewDb = disableGroupReviewDb;
+  }
+
+  public boolean writeToNoteDb() {
+    return writeToNoteDb;
+  }
+
+  public boolean readFromNoteDb() {
+    return readFromNoteDb;
+  }
+
+  public boolean disableGroupReviewDb() {
+    return disableGroupReviewDb;
+  }
+
+  public void setConfigValuesIfNotSetYet(Config cfg) {
+    Set<String> subsections = cfg.getSubsections(SECTION_NOTE_DB);
+    if (!subsections.contains(GROUPS.key())) {
+      cfg.setBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, writeToNoteDb());
+      cfg.setBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, readFromNoteDb());
+      cfg.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, disableGroupReviewDb());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/NoteDbTable.java b/java/com/google/gerrit/server/notedb/NoteDbTable.java
index be24e28..e299fdf 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbTable.java
@@ -16,6 +16,7 @@
 
 public enum NoteDbTable {
   ACCOUNTS,
+  GROUPS,
   CHANGES;
 
   public String key() {
diff --git a/java/com/google/gerrit/server/notedb/NotesMigration.java b/java/com/google/gerrit/server/notedb/NotesMigration.java
index e560ec8..9cee2cd 100644
--- a/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -54,12 +54,12 @@
  */
 public abstract class NotesMigration {
   public static final String SECTION_NOTE_DB = "noteDb";
+  public static final String READ = "read";
+  public static final String WRITE = "write";
+  public static final String DISABLE_REVIEW_DB = "disableReviewDb";
 
-  private static final String DISABLE_REVIEW_DB = "disableReviewDb";
   private static final String PRIMARY_STORAGE = "primaryStorage";
-  private static final String READ = "read";
   private static final String SEQUENCE = "sequence";
-  private static final String WRITE = "write";
 
   public static class Module extends AbstractModule {
     @Override
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index 8fce6d3..bf6e345 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -510,7 +510,7 @@
     byte[] aContent = aText.getContent();
     byte[] bContent = bText.getContent();
     long size = bContent.length;
-    long sizeDelta = bContent.length - aContent.length;
+    long sizeDelta = size - aContent.length;
     RawText aRawText = new RawText(aContent);
     RawText bRawText = new RawText(bContent);
     EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
diff --git a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
index 5f97134..8e162ba 100644
--- a/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
+++ b/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
@@ -16,11 +16,8 @@
 
 import static com.google.gerrit.server.plugins.PluginResource.PLUGIN_KIND;
 
-import com.google.gerrit.extensions.api.plugins.Plugins;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.api.plugins.PluginApiImpl;
-import com.google.gerrit.server.api.plugins.PluginsImpl;
 
 public class PluginRestApiModule extends RestApiModule {
   @Override
@@ -33,7 +30,5 @@
     post(PLUGIN_KIND, "disable").to(DisablePlugin.class);
     post(PLUGIN_KIND, "enable").to(EnablePlugin.class);
     post(PLUGIN_KIND, "reload").to(ReloadPlugin.class);
-    bind(Plugins.class).to(PluginsImpl.class);
-    factory(PluginApiImpl.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/server/project/GetCommit.java b/java/com/google/gerrit/server/project/GetCommit.java
index 2afeb07..d8fc5b6 100644
--- a/java/com/google/gerrit/server/project/GetCommit.java
+++ b/java/com/google/gerrit/server/project/GetCommit.java
@@ -18,12 +18,13 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.git.CommitUtil;
 import com.google.inject.Singleton;
+import java.io.IOException;
 
 @Singleton
 public class GetCommit implements RestReadView<CommitResource> {
 
   @Override
-  public CommitInfo apply(CommitResource rsrc) {
+  public CommitInfo apply(CommitResource rsrc) throws IOException {
     return CommitUtil.toCommitInfo(rsrc.getCommit());
   }
 }
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 3fe838d..4b2161b 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -134,6 +134,7 @@
     try {
       return checkedGet(projectName);
     } catch (IOException e) {
+      log.warn("Cannot read project " + projectName, e);
       return null;
     }
   }
@@ -232,6 +233,7 @@
     try {
       src = list.get(ListKey.ALL).tailSet(new Project.NameKey(pfx));
     } catch (ExecutionException e) {
+      log.warn("Cannot look up projects for prefix " + pfx, e);
       return Collections.emptyList();
     }
     return new Iterable<Project.NameKey>() {
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 398abd5..8ca3932 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -97,7 +97,7 @@
   private final ProjectCache projectCache;
   private final ChangeData cd;
 
-  private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.defaults();
+  private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.builder();
   private SubmitRuleOptions opts;
   private Change change;
   private CurrentUser user;
@@ -142,7 +142,7 @@
     if (opts != null) {
       optsBuilder = opts.toBuilder();
     } else {
-      optsBuilder = SubmitRuleOptions.defaults();
+      optsBuilder = SubmitRuleOptions.builder();
     }
     return this;
   }
diff --git a/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
index cb44b28..332aa75 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -26,11 +26,10 @@
 @AutoValue
 public abstract class SubmitRuleOptions {
   public static Builder builder() {
-    return new AutoValue_SubmitRuleOptions.Builder();
-  }
-
-  public static Builder defaults() {
-    return builder().allowClosed(false).skipFilters(false).rule(null);
+    return new AutoValue_SubmitRuleOptions.Builder()
+        .allowClosed(false)
+        .skipFilters(false)
+        .rule(null);
   }
 
   public abstract boolean allowClosed();
@@ -40,6 +39,8 @@
   @Nullable
   public abstract String rule();
 
+  public abstract Builder toBuilder();
+
   @AutoValue.Builder
   public abstract static class Builder {
     public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
@@ -50,8 +51,4 @@
 
     public abstract SubmitRuleOptions build();
   }
-
-  public Builder toBuilder() {
-    return builder().allowClosed(allowClosed()).skipFilters(skipFilters()).rule(rule());
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index a703852..b3e1c27 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.OrPredicate;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Change;
@@ -54,6 +55,11 @@
   }
 
   @Override
+  public ResultSet<FieldBundle> readRaw() throws OrmException {
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  @Override
   public boolean hasChange() {
     for (Predicate<ChangeData> p : getChildren()) {
       if (!(p instanceof ChangeDataSource) || !((ChangeDataSource) p).hasChange()) {
diff --git a/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java b/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
new file mode 100644
index 0000000..17eb56e
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
@@ -0,0 +1,141 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.util.concurrent.Futures;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.client.Key;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import java.util.Map;
+import java.util.function.Function;
+
+abstract class AbstractDisabledAccess<T, K extends Key<?>> implements Access<T, K> {
+  private static <T> ResultSet<T> empty() {
+    return new ListResultSet<>(ImmutableList.of());
+  }
+
+  @SuppressWarnings("deprecation")
+  private static <T>
+      com.google.common.util.concurrent.CheckedFuture<T, OrmException> emptyFuture() {
+    return Futures.immediateCheckedFuture(null);
+  }
+
+  // Don't even hold a reference to delegate, so it's not possible to use it
+  // accidentally.
+  private final ReviewDbWrapper wrapper;
+  private final String relationName;
+  private final int relationId;
+  private final Function<T, K> primaryKey;
+  private final Function<Iterable<T>, Map<K, T>> toMap;
+
+  AbstractDisabledAccess(ReviewDbWrapper wrapper, Access<T, K> delegate) {
+    this.wrapper = wrapper;
+    this.relationName = delegate.getRelationName();
+    this.relationId = delegate.getRelationID();
+    this.primaryKey = delegate::primaryKey;
+    this.toMap = delegate::toMap;
+  }
+
+  @Override
+  public final int getRelationID() {
+    return relationId;
+  }
+
+  @Override
+  public final String getRelationName() {
+    return relationName;
+  }
+
+  @Override
+  public final K primaryKey(T entity) {
+    return primaryKey.apply(entity);
+  }
+
+  @Override
+  public final Map<K, T> toMap(Iterable<T> iterable) {
+    return toMap.apply(iterable);
+  }
+
+  @Override
+  public final ResultSet<T> iterateAllEntities() {
+    return empty();
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  public final com.google.common.util.concurrent.CheckedFuture<T, OrmException> getAsync(K key) {
+    return emptyFuture();
+  }
+
+  @Override
+  public final ResultSet<T> get(Iterable<K> keys) {
+    return empty();
+  }
+
+  @Override
+  public final void insert(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void update(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void upsert(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void deleteKeys(Iterable<K> keys) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void delete(Iterable<T> instances) {
+    // Do nothing.
+  }
+
+  @Override
+  public final void beginTransaction(K key) {
+    // Keep track of when we've started a transaction so that we can avoid calling commit/rollback
+    // on the underlying ReviewDb. This is just a simple arm's-length approach, and may produce
+    // slightly different results from a native ReviewDb in corner cases like:
+    // * beginning transactions on different tables simultaneously
+    // * doing work between commit and rollback
+    // These kinds of things are already misuses of ReviewDb, and shouldn't be happening in current
+    // code anyway.
+    checkState(!wrapper.inTransaction(), "already in transaction");
+    wrapper.beginTransaction();
+  }
+
+  @Override
+  public final T atomicUpdate(K key, AtomicUpdate<T> update) {
+    return null;
+  }
+
+  @Override
+  public final T get(K id) {
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 3049e86..42c86c1 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -112,6 +112,12 @@
         grant(config, defaults, Permission.CREATE, admin);
       }
 
+      // Grant read permissions on the group branches to all users.
+      // This allows group owners to see the group refs. VisibleRefFilter ensures that read
+      // permissions for non-group-owners are ignored.
+      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
+      grant(config, groups, Permission.READ, false, true, registered);
+
       config.commit(md);
     }
   }
diff --git a/java/com/google/gerrit/server/schema/DataSourceProvider.java b/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 67f6894..98acc64 100644
--- a/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -138,6 +138,9 @@
               MILLISECONDS.convert(30, SECONDS),
               MILLISECONDS));
       ds.setInitialSize(ds.getMinIdle());
+      long evictIdleTimeMs = 1000L * 60;
+      ds.setMinEvictableIdleTimeMillis(evictIdleTimeMs);
+      ds.setTimeBetweenEvictionRunsMillis(evictIdleTimeMs / 2);
       ds.setValidationQuery(dst.getValidationQuery());
       ds.setValidationQueryTimeout(5);
       exportPoolMetrics(ds);
diff --git a/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
index fd0c7fc..7247490 100644
--- a/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
+++ b/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.common.collect.ImmutableList;
-import com.google.common.util.concurrent.Futures;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -31,14 +28,9 @@
 import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gwtorm.client.Key;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.ListResultSet;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
-import java.util.Map;
-import java.util.function.Function;
 
 /**
  * Wrapper for ReviewDb that never calls the underlying change tables.
@@ -50,20 +42,12 @@
     return new ListResultSet<>(ImmutableList.of());
   }
 
-  @SuppressWarnings("deprecation")
-  private static <T, K extends Key<?>>
-      com.google.common.util.concurrent.CheckedFuture<T, OrmException> emptyFuture() {
-    return Futures.immediateCheckedFuture(null);
-  }
-
   private final ChangeAccess changes;
   private final PatchSetApprovalAccess patchSetApprovals;
   private final ChangeMessageAccess changeMessages;
   private final PatchSetAccess patchSets;
   private final PatchLineCommentAccess patchComments;
 
-  private boolean inTransaction;
-
   NoChangesReviewDbWrapper(ReviewDb db) {
     super(db);
     changes = new Changes(this, delegate);
@@ -74,11 +58,6 @@
   }
 
   @Override
-  public boolean changesTablesEnabled() {
-    return false;
-  }
-
-  @Override
   public ChangeAccess changes() {
     return changes;
   }
@@ -103,128 +82,6 @@
     return patchComments;
   }
 
-  @Override
-  public void commit() throws OrmException {
-    if (!inTransaction) {
-      // This reads a little weird, we're not in a transaction, so why are we calling commit?
-      // Because we want to let the underlying ReviewDb do its normal thing in this case (which may
-      // be throwing an exception, or not, depending on implementation).
-      delegate.commit();
-    }
-  }
-
-  @Override
-  public void rollback() throws OrmException {
-    if (inTransaction) {
-      inTransaction = false;
-    } else {
-      // See comment in commit(): we want to let the underlying ReviewDb do its thing.
-      delegate.rollback();
-    }
-  }
-
-  private abstract static class AbstractDisabledAccess<T, K extends Key<?>>
-      implements Access<T, K> {
-    // Don't even hold a reference to delegate, so it's not possible to use it accidentally.
-    private final NoChangesReviewDbWrapper wrapper;
-    private final String relationName;
-    private final int relationId;
-    private final Function<T, K> primaryKey;
-    private final Function<Iterable<T>, Map<K, T>> toMap;
-
-    private AbstractDisabledAccess(NoChangesReviewDbWrapper wrapper, Access<T, K> delegate) {
-      this.wrapper = wrapper;
-      this.relationName = delegate.getRelationName();
-      this.relationId = delegate.getRelationID();
-      this.primaryKey = delegate::primaryKey;
-      this.toMap = delegate::toMap;
-    }
-
-    @Override
-    public final int getRelationID() {
-      return relationId;
-    }
-
-    @Override
-    public final String getRelationName() {
-      return relationName;
-    }
-
-    @Override
-    public final K primaryKey(T entity) {
-      return primaryKey.apply(entity);
-    }
-
-    @Override
-    public final Map<K, T> toMap(Iterable<T> iterable) {
-      return toMap.apply(iterable);
-    }
-
-    @Override
-    public final ResultSet<T> iterateAllEntities() {
-      return empty();
-    }
-
-    @SuppressWarnings("deprecation")
-    @Override
-    public final com.google.common.util.concurrent.CheckedFuture<T, OrmException> getAsync(K key) {
-      return emptyFuture();
-    }
-
-    @Override
-    public final ResultSet<T> get(Iterable<K> keys) {
-      return empty();
-    }
-
-    @Override
-    public final void insert(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void update(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void upsert(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void deleteKeys(Iterable<K> keys) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void delete(Iterable<T> instances) {
-      // Do nothing.
-    }
-
-    @Override
-    public final void beginTransaction(K key) {
-      // Keep track of when we've started a transaction so that we can avoid calling commit/rollback
-      // on the underlying ReviewDb. This is just a simple arm's-length approach, and may produce
-      // slightly different results from a native ReviewDb in corner cases like:
-      //  * beginning transactions on different tables simultaneously
-      //  * doing work between commit and rollback
-      // These kinds of things are already misuses of ReviewDb, and shouldn't be happening in
-      // current code anyway.
-      checkState(!wrapper.inTransaction, "already in transaction");
-      wrapper.inTransaction = true;
-    }
-
-    @Override
-    public final T atomicUpdate(K key, AtomicUpdate<T> update) {
-      return null;
-    }
-
-    @Override
-    public final T get(K id) {
-      return null;
-    }
-  }
-
   private static class Changes extends AbstractDisabledAccess<Change, Change.Id>
       implements ChangeAccess {
     private Changes(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
diff --git a/java/com/google/gerrit/server/schema/NoGroupsReviewDbWrapper.java b/java/com/google/gerrit/server/schema/NoGroupsReviewDbWrapper.java
new file mode 100644
index 0000000..33c4d77
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoGroupsReviewDbWrapper.java
@@ -0,0 +1,202 @@
+// 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.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gerrit.reviewdb.server.AccountGroupAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupByIdAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupByIdAudAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupMemberAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupMemberAuditAccess;
+import com.google.gerrit.reviewdb.server.AccountGroupNameAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+/**
+ * Wrapper for ReviewDb that never calls the underlying groups tables.
+ *
+ * <p>See {@link NotesMigrationSchemaFactory} for discussion.
+ */
+public class NoGroupsReviewDbWrapper extends ReviewDbWrapper {
+  private static <T> ResultSet<T> empty() {
+    return new ListResultSet<>(ImmutableList.of());
+  }
+
+  private final AccountGroupAccess groups;
+  private final AccountGroupNameAccess groupNames;
+  private final AccountGroupMemberAccess members;
+  private final AccountGroupMemberAuditAccess memberAudits;
+  private final AccountGroupByIdAccess byIds;
+  private final AccountGroupByIdAudAccess byIdAudits;
+
+  protected NoGroupsReviewDbWrapper(ReviewDb db) {
+    super(db);
+    this.groups = new Groups(this, delegate);
+    this.groupNames = new GroupNames(this, delegate);
+    this.members = new Members(this, delegate);
+    this.memberAudits = new MemberAudits(this, delegate);
+    this.byIds = new ByIds(this, delegate);
+    this.byIdAudits = new ByIdAudits(this, delegate);
+  }
+
+  @Override
+  public AccountGroupAccess accountGroups() {
+    return groups;
+  }
+
+  @Override
+  public AccountGroupNameAccess accountGroupNames() {
+    return groupNames;
+  }
+
+  @Override
+  public AccountGroupMemberAccess accountGroupMembers() {
+    return members;
+  }
+
+  @Override
+  public AccountGroupMemberAuditAccess accountGroupMembersAudit() {
+    return memberAudits;
+  }
+
+  @Override
+  public AccountGroupByIdAudAccess accountGroupByIdAud() {
+    return byIdAudits;
+  }
+
+  @Override
+  public AccountGroupByIdAccess accountGroupById() {
+    return byIds;
+  }
+
+  private static class Groups extends AbstractDisabledAccess<AccountGroup, AccountGroup.Id>
+      implements AccountGroupAccess {
+    private Groups(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroups());
+    }
+
+    @Override
+    public ResultSet<AccountGroup> byUUID(AccountGroup.UUID uuid) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroup> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class GroupNames
+      extends AbstractDisabledAccess<AccountGroupName, AccountGroup.NameKey>
+      implements AccountGroupNameAccess {
+    private GroupNames(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupNames());
+    }
+
+    @Override
+    public ResultSet<AccountGroupName> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class Members
+      extends AbstractDisabledAccess<AccountGroupMember, AccountGroupMember.Key>
+      implements AccountGroupMemberAccess {
+    private Members(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupMembers());
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byAccount(Account.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMember> byGroup(AccountGroup.Id id) throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class MemberAudits
+      extends AbstractDisabledAccess<AccountGroupMemberAudit, AccountGroupMemberAudit.Key>
+      implements AccountGroupMemberAuditAccess {
+    private MemberAudits(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupMembersAudit());
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroupAccount(
+        AccountGroup.Id groupId, com.google.gerrit.reviewdb.client.Account.Id accountId)
+        throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupMemberAudit> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class ByIds extends AbstractDisabledAccess<AccountGroupById, AccountGroupById.Key>
+      implements AccountGroupByIdAccess {
+    private ByIds(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupById());
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byIncludeUUID(AccountGroup.UUID uuid) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> byGroup(AccountGroup.Id id) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupById> all() throws OrmException {
+      return empty();
+    }
+  }
+
+  private static class ByIdAudits
+      extends AbstractDisabledAccess<AccountGroupByIdAud, AccountGroupByIdAud.Key>
+      implements AccountGroupByIdAudAccess {
+    private ByIdAudits(ReviewDbWrapper wrapper, ReviewDb db) {
+      super(wrapper, db.accountGroupByIdAud());
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroupInclude(
+        AccountGroup.Id groupId, AccountGroup.UUID incGroupUUID) throws OrmException {
+      return empty();
+    }
+
+    @Override
+    public ResultSet<AccountGroupByIdAud> byGroup(AccountGroup.Id groupId) throws OrmException {
+      return empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
index d73a5f4..9bc8b61 100644
--- a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
+++ b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.schema;
 
 import com.google.gerrit.reviewdb.server.DisallowReadFromChangesReviewDbWrapper;
+import com.google.gerrit.reviewdb.server.DisallowReadFromGroupsReviewDbWrapper;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -26,21 +28,20 @@
 public class NotesMigrationSchemaFactory implements SchemaFactory<ReviewDb> {
   private final SchemaFactory<ReviewDb> delegate;
   private final NotesMigration migration;
+  private final GroupsMigration groupsMigration;
 
   @Inject
   NotesMigrationSchemaFactory(
-      @ReviewDbFactory SchemaFactory<ReviewDb> delegate, NotesMigration migration) {
+      @ReviewDbFactory SchemaFactory<ReviewDb> delegate,
+      NotesMigration migration,
+      GroupsMigration groupsMigration) {
     this.delegate = delegate;
     this.migration = migration;
+    this.groupsMigration = groupsMigration;
   }
 
   @Override
   public ReviewDb open() throws OrmException {
-    ReviewDb db = delegate.open();
-    if (!migration.readChanges()) {
-      return db;
-    }
-
     // There are two levels at which this class disables access to Changes and related tables,
     // corresponding to two phases of the NoteDb migration:
     //
@@ -50,24 +51,45 @@
     //    support writing to ReviewDb. This behavior is accomplished by wrapping in a
     //    DisallowReadFromChangesReviewDbWrapper.
     //
-    //    Some codepaths might need to be able to read from ReviewDb if they really need to, because
-    //    they need to operate on the underlying source of truth, for example when reading a change
-    //    to determine its primary storage. To support this, ReviewDbUtil#unwrapDb can detect and
-    //    unwrap databases of this type.
+    //    Some codepaths might need to be able to read from ReviewDb if they really need to,
+    //    because they need to operate on the underlying source of truth, for example when reading
+    //    a change to determine its primary storage. To support this, ReviewDbUtil#unwrapDb can
+    //    detect and unwrap databases of this type.
     //
     // 2. After all changes have their primary storage in NoteDb, we can completely shut off access
     //    to the change tables. At this point in the migration, we are by definition not using the
     //    ReviewDb tables at all; we could even delete the tables at this point, and Gerrit would
     //    continue to function.
     //
-    //    This is accomplished by setting the delegate ReviewDb *underneath* DisallowReadFromChanges
-    //    to be a complete no-op, with NoChangesReviewDbWrapper. With this wrapper, all read
-    //    operations return no results, and write operations silently do nothing. This wrapper is
-    //    not a public class and nobody should ever attempt to unwrap it.
+    //    This is accomplished by setting the delegate ReviewDb *underneath*
+    //    DisallowReadFromChanges to be a complete no-op, with NoChangesReviewDbWrapper. With this
+    //    wrapper, all read operations return no results, and write operations silently do nothing.
+    //    This wrapper is not a public class and nobody should ever attempt to unwrap it.
 
-    if (migration.disableChangeReviewDb()) {
+    // First create the wrappers which can not be removed by ReviewDbUtil#unwrapDb(ReviewDb).
+    ReviewDb db = delegate.open();
+    if (migration.readChanges() && migration.disableChangeReviewDb()) {
+      // Disable writes to change tables in ReviewDb (ReviewDb access for changes are No-Ops).
       db = new NoChangesReviewDbWrapper(db);
     }
-    return new DisallowReadFromChangesReviewDbWrapper(db);
+
+    if (groupsMigration.readFromNoteDb() && groupsMigration.disableGroupReviewDb()) {
+      // Disable writes to group tables in ReviewDb (ReviewDb access for groups are No-Ops).
+      db = new NoGroupsReviewDbWrapper(db);
+    }
+
+    // Second create the wrappers which can be removed by ReviewDbUtil#unwrapDb(ReviewDb).
+    if (migration.readChanges()) {
+      // If reading changes from NoteDb is configured, changes should not be read from ReviewDb.
+      // Make sure that any attempt to read a change from ReviewDb anyway fails with an exception.
+      db = new DisallowReadFromChangesReviewDbWrapper(db);
+    }
+
+    if (groupsMigration.readFromNoteDb()) {
+      // If reading groups from NoteDb is configured, groups should not be read from ReviewDb.
+      // Make sure that any attempt to read a group from ReviewDb anyway fails with an exception.
+      db = new DisallowReadFromGroupsReviewDbWrapper(db);
+    }
+    return db;
   }
 }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreator.java b/java/com/google/gerrit/server/schema/SchemaCreator.java
index 9060b06..112fd55 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.metrics.MetricMaker;
@@ -38,12 +39,15 @@
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.update.RefUpdateUtil;
 import com.google.gwtorm.jdbc.JdbcExecutor;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -53,6 +57,7 @@
 import java.nio.file.Path;
 import java.util.Collections;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -68,7 +73,7 @@
   private final PersonIdent serverUser;
   private final DataSourceType dataSourceType;
   private final GroupIndexCollection indexCollection;
-  private final boolean writeGroupsToNoteDb;
+  private final GroupsMigration groupsMigration;
 
   private final Config config;
   private final MetricMaker metricMaker;
@@ -85,6 +90,7 @@
       @GerritPersonIdent PersonIdent au,
       DataSourceType dst,
       GroupIndexCollection ic,
+      GroupsMigration gm,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
       NotesMigration migration,
@@ -98,6 +104,7 @@
         au,
         dst,
         ic,
+        gm,
         config,
         metricMaker,
         migration,
@@ -113,6 +120,7 @@
       @GerritPersonIdent PersonIdent au,
       DataSourceType dst,
       GroupIndexCollection ic,
+      GroupsMigration gm,
       Config config,
       MetricMaker metricMaker,
       NotesMigration migration,
@@ -125,11 +133,7 @@
     serverUser = au;
     dataSourceType = dst;
     indexCollection = ic;
-    // TODO(aliceks): Remove this flag when all other necessary TODOs for writing groups to NoteDb
-    // have been addressed.
-    // Don't flip this flag in a production setting! We only added it to spread the implementation
-    // of groups in NoteDb among several changes which are gradually merged.
-    writeGroupsToNoteDb = config.getBoolean("user", null, "writeGroupsToNoteDb", false);
+    groupsMigration = gm;
 
     this.config = config;
     this.allProjectsName = apName;
@@ -166,28 +170,28 @@
             allProjectsName,
             allUsersName,
             metricMaker);
-    try (Repository repository = repoManager.openRepository(allUsersName)) {
-      createAdminsGroup(db, seqs, repository, admins);
-      createBatchUsersGroup(db, seqs, repository, batchUsers, admins.getUUID());
+    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+      createAdminsGroup(db, seqs, allUsersRepo, admins);
+      createBatchUsersGroup(db, seqs, allUsersRepo, batchUsers, admins.getUUID());
     }
 
     dataSourceType.getIndexScript().run(db);
   }
 
   private void createAdminsGroup(
-      ReviewDb db, Sequences seqs, Repository repository, GroupReference groupReference)
+      ReviewDb db, Sequences seqs, Repository allUsersRepo, GroupReference groupReference)
       throws OrmException, IOException, ConfigInvalidException {
     InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
     InternalGroupUpdate groupUpdate =
         InternalGroupUpdate.builder().setDescription("Gerrit Site Administrators").build();
 
-    createGroup(db, repository, groupCreation, groupUpdate);
+    createGroup(db, allUsersRepo, groupCreation, groupUpdate);
   }
 
   private void createBatchUsersGroup(
       ReviewDb db,
       Sequences seqs,
-      Repository repository,
+      Repository allUsersRepo,
       GroupReference groupReference,
       AccountGroup.UUID adminsGroupUuid)
       throws OrmException, IOException, ConfigInvalidException {
@@ -198,23 +202,23 @@
             .setOwnerGroupUUID(adminsGroupUuid)
             .build();
 
-    createGroup(db, repository, groupCreation, groupUpdate);
+    createGroup(db, allUsersRepo, groupCreation, groupUpdate);
   }
 
   private void createGroup(
       ReviewDb db,
-      Repository repository,
+      Repository allUsersRepo,
       InternalGroupCreation groupCreation,
       InternalGroupUpdate groupUpdate)
       throws OrmException, ConfigInvalidException, IOException {
     InternalGroup groupInReviewDb = createGroupInReviewDb(db, groupCreation, groupUpdate);
 
-    if (!writeGroupsToNoteDb) {
+    if (!groupsMigration.writeToNoteDb()) {
       index(groupInReviewDb);
       return;
     }
 
-    InternalGroup createdGroup = createGroupInNoteDb(repository, groupCreation, groupUpdate);
+    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupUpdate);
     index(createdGroup);
   }
 
@@ -228,23 +232,43 @@
   }
 
   private InternalGroup createGroupInNoteDb(
-      Repository repository, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
       throws ConfigInvalidException, IOException, OrmDuplicateKeyException {
-    GroupConfig groupConfig = GroupConfig.createForNewGroup(repository, groupCreation);
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
     // We don't add any initial members or subgroups and hence the provided functions should never
     // be called. To be on the safe side, we specify some valid functions.
     groupConfig.setGroupUpdate(groupUpdate, Account.Id::toString, AccountGroup.UUID::get);
-    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(repository)) {
-      groupConfig.commit(metaDataUpdate);
-    }
+
+    AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+    GroupNameNotes groupNameNotes =
+        GroupNameNotes.loadForNewGroup(allUsersRepo, groupCreation.getGroupUUID(), groupName);
+
+    commit(allUsersRepo, groupConfig, groupNameNotes);
+
     return groupConfig
         .getLoadedGroup()
         .orElseThrow(() -> new IllegalStateException("Created group wasn't automatically loaded"));
   }
 
-  private MetaDataUpdate createMetaDataUpdate(Repository repository) {
+  private void commit(
+      Repository allUsersRepo, GroupConfig groupConfig, GroupNameNotes groupNameNotes)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
+      groupConfig.commit(metaDataUpdate);
+    }
+    // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
+      groupNameNotes.commit(metaDataUpdate);
+    }
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+  }
+
+  private MetaDataUpdate createMetaDataUpdate(
+      Repository allUsersRepo, @Nullable BatchRefUpdate batchRefUpdate) {
     MetaDataUpdate metaDataUpdate =
-        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repository);
+        new MetaDataUpdate(
+            GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo, batchRefUpdate);
     metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
     metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
     return metaDataUpdate;
diff --git a/java/com/google/gerrit/server/schema/SchemaVersion.java b/java/com/google/gerrit/server/schema/SchemaVersion.java
index 2cb57a8..780b70a 100644
--- a/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -35,7 +35,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_163> C = Schema_163.class;
+  public static final Class<Schema_164> C = Schema_164.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/java/com/google/gerrit/server/schema/Schema_164.java b/java/com/google/gerrit/server/schema/Schema_164.java
new file mode 100644
index 0000000..fca0ae8
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_164.java
@@ -0,0 +1,85 @@
+// 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 static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.schema.AclUtil.grant;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Grant read on group branches */
+public class Schema_164 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Grant read permissions on group branches";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final SystemGroupBackend systemGroupBackend;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_164(
+      Provider<Schema_163> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      SystemGroupBackend systemGroupBackend,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.systemGroupBackend = systemGroupBackend;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, git)) {
+      md.getCommitBuilder().setAuthor(serverUser);
+      md.getCommitBuilder().setCommitter(serverUser);
+      md.setMessage(COMMIT_MSG);
+
+      ProjectConfig config = ProjectConfig.read(md);
+      AccessSection groups = config.getAccessSection(RefNames.REFS_GROUPS + "*", true);
+      grant(
+          config,
+          groups,
+          Permission.READ,
+          false,
+          true,
+          systemGroupBackend.getGroup(REGISTERED_USERS));
+      config.commit(md);
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to grant read permissions on group branches", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RefUpdateUtil.java b/java/com/google/gerrit/server/update/RefUpdateUtil.java
index 17c7812..166f288 100644
--- a/java/com/google/gerrit/server/update/RefUpdateUtil.java
+++ b/java/com/google/gerrit/server/update/RefUpdateUtil.java
@@ -20,6 +20,7 @@
 import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -28,7 +29,25 @@
   /**
    * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
    *
+   * <p>Creates a new {@link RevWalk} used only for this operation.
+   *
    * @param bru batch update; should already have been executed.
+   * @param repo repository that created {@code bru}.
+   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
+   *     #checkResults(BatchRefUpdate)} for details.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  public static void executeChecked(BatchRefUpdate bru, Repository repo) throws IOException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      executeChecked(bru, rw);
+    }
+  }
+
+  /**
+   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
+   *
+   * @param bru batch update; should already have been executed.
+   * @param rw walk for executing the update.
    * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
    *     #checkResults(BatchRefUpdate)} for details.
    * @throws IOException if any result was not {@code OK}.
diff --git a/java/com/google/gerrit/sshd/SshScope.java b/java/com/google/gerrit/sshd/SshScope.java
index 963a71a..2659831 100644
--- a/java/com/google/gerrit/sshd/SshScope.java
+++ b/java/com/google/gerrit/sshd/SshScope.java
@@ -102,7 +102,7 @@
 
     synchronized Context subContext(SshSession newSession, String newCommandLine) {
       Context ctx = new Context(this, newSession, newCommandLine);
-      cleanup.add(ctx.cleanup);
+      ctx.cleanup.add(cleanup);
       return ctx;
     }
   }
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index e8d20d0..8e13b61 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -23,6 +23,7 @@
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server:module",
+        "//java/com/google/gerrit/server/api",
         "//java/com/google/gerrit/server/cache/h2",
         "//lib:gwtorm",
         "//lib:h2",
diff --git a/java/com/google/gerrit/testing/GroupNoteDbMode.java b/java/com/google/gerrit/testing/GroupNoteDbMode.java
new file mode 100644
index 0000000..86e92b8
--- /dev/null
+++ b/java/com/google/gerrit/testing/GroupNoteDbMode.java
@@ -0,0 +1,78 @@
+// 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.testing;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Enums;
+import com.google.common.base.Strings;
+import com.google.gerrit.server.notedb.GroupsMigration;
+
+public enum GroupNoteDbMode {
+  /** NoteDb is disabled, groups are only in ReviewDb */
+  OFF(new GroupsMigration(false, false, false)),
+
+  /** Writing new groups to NoteDb is enabled. */
+  WRITE(new GroupsMigration(true, false, false)),
+
+  /**
+   * Reading/writing groups from/to NoteDb is enabled. Trying to read groups from ReviewDb throws an
+   * exception.
+   */
+  READ_WRITE(new GroupsMigration(true, true, false)),
+
+  /**
+   * All group tables in ReviewDb are entirely disabled. Trying to read groups from ReviewDb throws
+   * an exception. Reading groups through an unwrapped ReviewDb instance writing groups to ReviewDb
+   * is a No-Op.
+   */
+  ON(new GroupsMigration(true, true, true));
+
+  private static final String ENV_VAR = "GERRIT_NOTEDB_GROUPS";
+  private static final String SYS_PROP = "gerrit.notedb.groups";
+
+  public static GroupNoteDbMode get() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return OFF;
+    }
+    value = value.toUpperCase().replace("-", "_");
+    GroupNoteDbMode mode = Enums.getIfPresent(GroupNoteDbMode.class, value).orNull();
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          mode != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          mode != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return mode;
+  }
+
+  private final GroupsMigration groupsMigration;
+
+  private GroupNoteDbMode(GroupsMigration groupsMigration) {
+    this.groupsMigration = groupsMigration;
+  }
+
+  public GroupsMigration getGroupsMigration() {
+    return groupsMigration;
+  }
+}
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b5c2187..72629c7 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.cache.h2.DefaultCacheFactory;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -161,6 +162,7 @@
             });
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+    install(new GerritApiModule());
     install(new DefaultPermissionBackendModule());
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index 0490429..c196026 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -96,7 +96,10 @@
 
         if (fileRegex.matcher(newName).find()
             || (oldName != null && fileRegex.matcher(oldName).find())) {
-          List<Edit> edits = entry.getEdits();
+          // This cast still seems to be needed on JDK 8 as workaround for:
+          // https://bugs.openjdk.java.net/browse/JDK-8039214
+          @SuppressWarnings("cast")
+          List<Edit> edits = (List<Edit>) entry.getEdits();
 
           if (edits.isEmpty()) {
             continue;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index ce4d77b..67c014d 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -35,6 +35,7 @@
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.google.common.cache.LoadingCache;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -79,12 +80,12 @@
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.WatchConfig;
@@ -93,7 +94,8 @@
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.account.AccountIndexer;
+import com.google.gerrit.server.index.account.StalenessChecker;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.RefPattern;
@@ -103,6 +105,7 @@
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.name.Named;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -114,6 +117,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.bouncycastle.bcpg.ArmoredOutputStream;
@@ -122,8 +126,12 @@
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -153,6 +161,8 @@
 
   @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
 
+  @Inject private ExternalIdsUpdate.ServerNoReindex externalIdsUpdateNoReindexFactory;
+
   @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
 
   @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
@@ -163,6 +173,14 @@
 
   @Inject protected Emails emails;
 
+  @Inject private StalenessChecker stalenessChecker;
+
+  @Inject private AccountIndexer accountIndexer;
+
+  @Inject
+  @Named("accounts")
+  private LoadingCache<Account.Id, Optional<AccountState>> accountsCache;
+
   private AccountIndexedCounter accountIndexedCounter;
   private RegistrationHandle accountIndexEventCounterHandle;
   private RefUpdateCounter refUpdateCounter;
@@ -918,11 +936,11 @@
   @Test
   public void pushAccountConfigWithPrefEmailThatDoesNotExistAsExtIdToUserBranchForReviewAndSubmit()
       throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"));
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
     String userRef = RefNames.refsUsers(foo.id);
     accountIndexedCounter.clear();
 
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
 
@@ -934,7 +952,7 @@
         pushFactory
             .create(
                 db,
-                admin.getIdent(),
+                foo.getIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountConfig.ACCOUNT_CONFIG,
@@ -1058,17 +1076,18 @@
   }
 
   @Test
+  @Sandboxed
   public void pushAccountConfigToUserBranchForReviewDeactivateOtherAccount() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestAccount foo = accountCreator.create(name("foo"));
     assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
     String userRef = RefNames.refsUsers(foo.id);
     accountIndexedCounter.clear();
 
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
-    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroup.getGroupUUID(), false);
-    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroup.getGroupUUID());
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
+    grantLabel("Code-Review", -2, 2, allUsers, userRef, false, adminGroupUuid(), false);
+    grant(allUsers, userRef, Permission.SUBMIT, false, adminGroupUuid());
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1229,18 +1248,15 @@
 
   @Test
   public void pushAccountConfigToUserBranchInvalidPreferredEmailButNotChanged() throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"));
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
     String userRef = RefNames.refsUsers(foo.id);
 
     String noEmail = "no.email";
     accountsUpdate.create().update(foo.id, a -> a.setPreferredEmail(noEmail));
     accountIndexedCounter.clear();
 
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
-
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    grant(allUsers, userRef, Permission.PUSH, false, REGISTERED_USERS);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
 
@@ -1251,7 +1267,7 @@
     pushFactory
         .create(
             db,
-            admin.getIdent(),
+            foo.getIdent(),
             allUsersRepo,
             "Update account config",
             AccountConfig.ACCOUNT_CONFIG,
@@ -1268,15 +1284,13 @@
 
   @Test
   public void pushAccountConfigToUserBranchIfPreferredEmailDoesNotExistAsExtId() throws Exception {
-    TestAccount foo = accountCreator.create(name("foo"));
+    TestAccount foo = accountCreator.create(name("foo"), name("foo") + "@example.com", "Foo");
     String userRef = RefNames.refsUsers(foo.id);
     accountIndexedCounter.clear();
 
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
 
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
 
@@ -1287,7 +1301,7 @@
     pushFactory
         .create(
             db,
-            admin.getIdent(),
+            foo.getIdent(),
             allUsersRepo,
             "Update account config",
             AccountConfig.ACCOUNT_CONFIG,
@@ -1326,15 +1340,16 @@
   }
 
   @Test
+  @Sandboxed
   public void pushAccountConfigToUserBranchDeactivateOtherAccount() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestAccount foo = accountCreator.create(name("foo"));
     assertThat(gApi.accounts().id(foo.id.get()).getActive()).isTrue();
     String userRef = RefNames.refsUsers(foo.id);
     accountIndexedCounter.clear();
 
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
+    grant(allUsers, userRef, Permission.PUSH, false, adminGroupUuid());
 
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, userRef + ":userRef");
@@ -1776,6 +1791,101 @@
     assertGroups(newUser, ImmutableList.of(group));
   }
 
+  @Test
+  public void defaultPermissionsOnUserBranches() throws Exception {
+    String userRef = RefNames.REFS_USERS + "${" + RefPattern.USERID_SHARDED + "}";
+    assertPermissions(
+        allUsers,
+        groupRef(REGISTERED_USERS),
+        userRef,
+        true,
+        Permission.READ,
+        Permission.PUSH,
+        Permission.SUBMIT);
+
+    // TODO(ekempin): This permission should also be exclusive
+    assertLabelPermission(
+        allUsers, groupRef(REGISTERED_USERS), userRef, false, "Code-Review", -2, 2);
+
+    assertPermissions(
+        allUsers,
+        adminGroupRef(),
+        RefNames.REFS_USERS_DEFAULT,
+        true,
+        Permission.READ,
+        Permission.PUSH,
+        Permission.CREATE);
+  }
+
+  @Test
+  public void stalenessChecker() throws Exception {
+    // Newly created account is not stale.
+    AccountInfo accountInfo = gApi.accounts().create(name("foo")).get();
+    Account.Id accountId = new Account.Id(accountInfo._accountId);
+    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+
+    // Manually updating the user ref makes the index document stale.
+    String userRef = RefNames.refsUsers(accountId);
+    try (Repository repo = repoManager.openRepository(allUsers);
+        ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(commit.getTree());
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage(commit.getFullMessage());
+      ObjectId emptyCommit = oi.insert(cb);
+      oi.flush();
+
+      RefUpdate updateRef = repo.updateRef(userRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(emptyCommit);
+      assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleAccountAndReindex(accountId);
+
+    // Manually inserting/updating/deleting an external ID of the user makes the index document
+    // stale.
+    ExternalIdsUpdate externalIdsUpdateNoReindex = externalIdsUpdateNoReindexFactory.create();
+    ExternalId.Key key = ExternalId.Key.create("foo", "foo");
+    externalIdsUpdateNoReindex.insert(ExternalId.create(key, accountId));
+    assertStaleAccountAndReindex(accountId);
+
+    externalIdsUpdateNoReindex.upsert(
+        ExternalId.createWithEmail(key, accountId, "foo@example.com"));
+    assertStaleAccountAndReindex(accountId);
+
+    externalIdsUpdateNoReindex.delete(accountId, key);
+    assertStaleAccountAndReindex(accountId);
+
+    // Manually delete account
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+      RefUpdate updateRef = repo.updateRef(userRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(ObjectId.zeroId());
+      updateRef.setForceUpdate(true);
+      assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleAccountAndReindex(accountId);
+  }
+
+  private void assertStaleAccountAndReindex(Account.Id accountId) throws IOException {
+    // Evict account from cache to be sure that we use the index state for staleness checks. This
+    // has to happen directly on the accounts cache because AccountCacheImpl triggers a reindex for
+    // the account.
+    accountsCache.invalidate(accountId);
+    assertThat(stalenessChecker.isStale(accountId)).isTrue();
+
+    // Reindex fixes staleness
+    accountIndexer.index(accountId);
+    assertThat(stalenessChecker.isStale(accountId)).isFalse();
+  }
+
   private void assertGroups(String user, List<String> expected) throws Exception {
     List<String> actual =
         gApi.accounts().id(user).getGroups().stream().map(g -> g.name).collect(toList());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index bfd4aff..d01b64e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -1228,11 +1228,7 @@
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
     ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(
-        cfg,
-        Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
-        "refs/*");
+    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
 
@@ -1305,11 +1301,7 @@
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
     ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(
-        cfg,
-        Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
-        "refs/*");
+    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
 
@@ -1352,11 +1344,7 @@
     // create hidden project that is only visible to administrators
     Project.NameKey p = createProject("p");
     ProjectConfig cfg = projectCache.checkedGet(p).getConfig();
-    Util.allow(
-        cfg,
-        Permission.READ,
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID(),
-        "refs/*");
+    Util.allow(cfg, Permission.READ, adminGroupUuid(), "refs/*");
     Util.block(cfg, Permission.READ, REGISTERED_USERS, "refs/*");
     saveProjectConfig(p, cfg);
 
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
new file mode 100644
index 0000000..a5d93f6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
@@ -0,0 +1,209 @@
+// 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.api.group;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
+import static com.google.gerrit.server.notedb.NotesMigration.READ;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.group.ServerInitiated;
+import com.google.gerrit.server.group.db.GroupBundle;
+import com.google.gerrit.server.group.db.GroupRebuilder;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gerrit.testing.TestTimeUtil.TempClockStep;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class GroupRebuilderIT extends AbstractDaemonTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config config = new Config();
+    // This test is explicitly testing the migration from ReviewDb to NoteDb, and handles reading
+    // from NoteDb manually. It should work regardless of the value of noteDb.groups.write, however.
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, false);
+    return config;
+  }
+
+  @Inject @GerritServerId private String serverId;
+  @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdate;
+  @Inject private GroupBundle.Factory bundleFactory;
+  @Inject private GroupRebuilder rebuilder;
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void basicGroupProperties() throws Exception {
+    GroupInfo createdGroup = gApi.groups().create(name("group")).get();
+    try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(createdGroup.groupId));
+      deleteGroupRefs(reviewDbBundle);
+
+      assertThat(rebuild(reviewDbBundle)).isEqualTo(reviewDbBundle.truncateToSecond());
+    }
+  }
+
+  @Test
+  public void logFormat() throws Exception {
+    TestAccount user2 = accountCreator.user2();
+    GroupInfo group1 = gApi.groups().create(name("group1")).get();
+    GroupInfo group2 = gApi.groups().create(name("group2")).get();
+
+    try (TempClockStep step = TestTimeUtil.freezeClock()) {
+      gApi.groups().id(group1.id).addMembers(user.id.toString(), user2.id.toString());
+    }
+
+    gApi.groups().id(group1.id).addGroups(group2.id);
+
+    try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(group1.groupId));
+      deleteGroupRefs(reviewDbBundle);
+
+      GroupBundle noteDbBundle = rebuild(reviewDbBundle);
+      assertThat(noteDbBundle).isEqualTo(reviewDbBundle.truncateToSecond());
+
+      ImmutableList<CommitInfo> log = log(group1);
+      assertThat(log).hasSize(4);
+
+      assertThat(log.get(0)).message().isEqualTo("Create group");
+      assertThat(log.get(0)).author().name().isEqualTo(serverIdent.get().getName());
+      assertThat(log.get(0)).author().email().isEqualTo(serverIdent.get().getEmailAddress());
+      assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
+      assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.get().getTimeZoneOffset());
+      assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
+
+      assertThat(log.get(1))
+          .message()
+          .isEqualTo("Update group\n\nAdd: Administrator <" + admin.id + "@" + serverId + ">");
+      assertThat(log.get(1)).author().name().isEqualTo(admin.fullName);
+      assertThat(log.get(1)).author().email().isEqualTo(admin.id + "@" + serverId);
+      assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
+
+      assertThat(log.get(2))
+          .message()
+          .isEqualTo(
+              "Update group\n"
+                  + "\n"
+                  + ("Add: User <" + user.id + "@" + serverId + ">\n")
+                  + ("Add: User2 <" + user2.id + "@" + serverId + ">"));
+      assertThat(log.get(2)).author().name().isEqualTo(admin.fullName);
+      assertThat(log.get(2)).author().email().isEqualTo(admin.id + "@" + serverId);
+      assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
+
+      assertThat(log.get(3))
+          .message()
+          .isEqualTo("Update group\n\nAdd-group: " + group2.name + " <" + group2.id + ">");
+      assertThat(log.get(3)).author().name().isEqualTo(admin.fullName);
+      assertThat(log.get(3)).author().email().isEqualTo(admin.id + "@" + serverId);
+      assertThat(log.get(3)).committer().hasSameDateAs(log.get(3).author);
+    }
+  }
+
+  private void deleteGroupRefs(GroupBundle bundle) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      String refName = RefNames.refsGroups(bundle.uuid());
+      RefUpdate ru = repo.updateRef(refName);
+      ru.setForceUpdate(true);
+      Ref oldRef = repo.exactRef(refName);
+      if (oldRef == null) {
+        return;
+      }
+      ru.setExpectedOldObjectId(oldRef.getObjectId());
+      ru.setNewObjectId(ObjectId.zeroId());
+      assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+  }
+
+  private GroupBundle rebuild(GroupBundle reviewDbBundle) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      rebuilder.rebuild(repo, reviewDbBundle, null);
+      return bundleFactory.fromNoteDb(repo, reviewDbBundle.uuid());
+    }
+  }
+
+  private ImmutableList<CommitInfo> log(GroupInfo g) throws Exception {
+    ImmutableList.Builder<CommitInfo> result = ImmutableList.builder();
+    List<Date> commitDates = new ArrayList<>();
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(RefNames.refsGroups(new AccountGroup.UUID(g.id)));
+      if (ref != null) {
+        rw.sort(RevSort.REVERSE);
+        rw.setRetainBody(true);
+        rw.markStart(rw.parseCommit(ref.getObjectId()));
+        for (RevCommit c : rw) {
+          result.add(CommitUtil.toCommitInfo(c));
+          commitDates.add(c.getCommitterIdent().getWhen());
+        }
+      }
+    }
+    assertThat(commitDates).named("commit timestamps for %s", result).isOrdered();
+    return result.build();
+  }
+
+  private class BlockReviewDbUpdatesForGroups implements AutoCloseable {
+    BlockReviewDbUpdatesForGroups() {
+      blockReviewDbUpdates(true);
+    }
+
+    @Override
+    public void close() throws Exception {
+      blockReviewDbUpdates(false);
+    }
+
+    private void blockReviewDbUpdates(boolean block) {
+      cfg.setBoolean("user", null, "blockReviewDbGroupUpdates", block);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index ae3e635..a000d9e 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -22,8 +22,15 @@
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfos;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
+import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.READ;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.WRITE;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Throwables;
+import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -33,6 +40,7 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -50,6 +58,7 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Account;
@@ -59,20 +68,26 @@
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.index.group.GroupIndexer;
+import com.google.gerrit.server.index.group.StalenessChecker;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.name.Named;
 import java.io.IOException;
 import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -83,9 +98,12 @@
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.After;
+import org.junit.Before;
 import org.junit.Test;
 
 @NoHttpd
@@ -93,13 +111,36 @@
   @ConfigSuite.Config
   public static Config noteDbConfig() {
     Config config = new Config();
-    config.setBoolean("user", null, "writeGroupsToNoteDb", true);
-    config.setBoolean("user", null, "readGroupsFromNoteDb", true);
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, true);
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, true);
+    return config;
+  }
+
+  @ConfigSuite.Config
+  public static Config disableReviewDb() {
+    Config config = noteDbConfig();
+    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, true);
     return config;
   }
 
   @Inject private Groups groups;
   @Inject private GroupIncludeCache groupIncludeCache;
+  @Inject private StalenessChecker stalenessChecker;
+  @Inject private GroupIndexer groupIndexer;
+
+  @Inject
+  @Named("groups_byuuid")
+  private LoadingCache<String, Optional<InternalGroup>> groupsByUUIDCache;
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+  }
 
   @Test
   public void systemGroupCanBeRetrievedFromIndex() throws Exception {
@@ -220,7 +261,7 @@
   public void createGroup() throws Exception {
     String newGroupName = name("newGroup");
     GroupInfo g = gApi.groups().create(newGroupName).get();
-    assertGroupInfo(getFromCache(newGroupName), g);
+    assertGroupInfo(group(newGroupName), g);
   }
 
   @Test
@@ -280,7 +321,7 @@
     in.name = name("newGroup");
     in.description = "Test description";
     in.visibleToAll = true;
-    in.ownerId = getFromCache("Administrators").getGroupUUID().get();
+    in.ownerId = adminGroupUuid().get();
     GroupInfo g = gApi.groups().create(in).detail();
     assertThat(g.description).isEqualTo(in.description);
     assertThat(g.options.visibleToAll).isEqualTo(in.visibleToAll);
@@ -297,7 +338,7 @@
   @Test
   public void createdOnFieldIsPopulatedForNewGroup() throws Exception {
     // NoteDb allows only second precision.
-    Timestamp testStartTime = Timestamp.from(Instant.now().truncatedTo(ChronoUnit.SECONDS));
+    Timestamp testStartTime = TimeUtil.truncateToSecond(TimeUtil.nowTs());
     String newGroupName = name("newGroup");
     GroupInfo group = gApi.groups().create(newGroupName).get();
 
@@ -306,7 +347,7 @@
 
   @Test
   public void getGroup() throws Exception {
-    InternalGroup adminGroup = getFromCache("Administrators");
+    InternalGroup adminGroup = adminGroup();
     testGetGroup(adminGroup.getGroupUUID().get(), adminGroup);
     testGetGroup(adminGroup.getName(), adminGroup);
     testGetGroup(adminGroup.getId().get(), adminGroup);
@@ -346,40 +387,91 @@
   }
 
   @Test
-  public void groupName() throws Exception {
-    String name = name("group");
+  public void groupIsCreatedForSpecifiedName() throws Exception {
+    String name = name("Users");
     gApi.groups().create(name);
 
-    // get name
     assertThat(gApi.groups().id(name).name()).isEqualTo(name);
-
-    // set name to same name
-    gApi.groups().id(name).name(name);
-    assertThat(gApi.groups().id(name).name()).isEqualTo(name);
-
-    // set name with name conflict
-    String other = name("other");
-    gApi.groups().create(other);
-    exception.expect(ResourceConflictException.class);
-    gApi.groups().id(name).name(other);
   }
 
   @Test
-  public void groupRename() throws Exception {
-    String name = name("group");
+  public void groupCannotBeCreatedWithNameOfAnotherGroup() throws Exception {
+    String name = name("Users");
+    gApi.groups().create(name).get();
+
+    exception.expect(ResourceConflictException.class);
     gApi.groups().create(name);
+  }
 
-    String newName = name("newName");
+  @Test
+  public void groupCanBeRenamed() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
     gApi.groups().id(name).name(newName);
-    assertThat(getFromCache(newName)).isNotNull();
-    assertThat(gApi.groups().id(newName).name()).isEqualTo(newName);
+    assertThat(gApi.groups().id(group.id).name()).isEqualTo(newName);
+  }
 
-    assertThat(getFromCache(name)).isNull();
+  @Test
+  public void groupCanBeRenamedToItsCurrentName() throws Exception {
+    String name = name("Users");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    gApi.groups().id(group.id).name(name);
+    assertThat(gApi.groups().id(group.id).name()).isEqualTo(name);
+  }
+
+  @Test
+  public void groupCannotBeRenamedToNameOfAnotherGroup() throws Exception {
+    String name1 = name("Name1");
+    GroupInfo group1 = gApi.groups().create(name1).get();
+
+    String name2 = name("Name2");
+    gApi.groups().create(name2);
+
+    exception.expect(ResourceConflictException.class);
+    gApi.groups().id(group1.id).name(name2);
+  }
+
+  @Test
+  public void renamedGroupCanBeLookedUpByNewName() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
+    gApi.groups().id(group.id).name(newName);
+
+    GroupInfo foundGroup = gApi.groups().id(newName).get();
+    assertThat(foundGroup.id).isEqualTo(group.id);
+  }
+
+  @Test
+  public void oldNameOfRenamedGroupIsNotAccessibleAnymore() throws Exception {
+    String name = name("Name1");
+    GroupInfo group = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
+    gApi.groups().id(group.id).name(newName);
+
+    assertGroupDoesNotExist(name);
     exception.expect(ResourceNotFoundException.class);
     gApi.groups().id(name).get();
   }
 
   @Test
+  public void oldNameOfRenamedGroupIsFreeForUseAgain() throws Exception {
+    String name = name("Name1");
+    GroupInfo group1 = gApi.groups().create(name).get();
+
+    String newName = name("Name2");
+    gApi.groups().id(group1.id).name(newName);
+
+    GroupInfo group2 = gApi.groups().create(name).get();
+    assertThat(group2.id).isNotEqualTo(group1.id);
+  }
+
+  @Test
   public void groupDescription() throws Exception {
     String name = name("group");
     gApi.groups().create(name);
@@ -420,7 +512,7 @@
   public void groupOwner() throws Exception {
     String name = name("group");
     GroupInfo info = gApi.groups().create(name).get();
-    String adminUUID = getFromCache("Administrators").getGroupUUID().get();
+    String adminUUID = adminGroupUuid().get();
     String registeredUUID = SystemGroupBackend.REGISTERED_USERS.get();
 
     // get owner
@@ -579,7 +671,7 @@
   @Test
   public void listAllGroups() throws Exception {
     List<String> expectedGroups =
-        groups.getAll(db).map(InternalGroup::getName).sorted().collect(toList());
+        groups.getAllGroupReferences(db).map(GroupReference::getName).sorted().collect(toList());
     assertThat(expectedGroups.size()).isAtLeast(2);
     assertThat(gApi.groups().list().getAsMap().keySet())
         .containsExactlyElementsIn(expectedGroups)
@@ -593,8 +685,7 @@
         Arrays.asList(createGroup("test-child1", parent), createGroup("test-child2", parent));
 
     // By UUID
-    List<GroupInfo> owned =
-        gApi.groups().list().withOwnedBy(getFromCache(parent).getGroupUUID().get()).get();
+    List<GroupInfo> owned = gApi.groups().list().withOwnedBy(groupUuid(parent).get()).get();
     assertThat(owned.stream().map(g -> g.name).collect(toList()))
         .containsExactlyElementsIn(children);
 
@@ -620,7 +711,7 @@
     in.name = newGroupName;
     in.description = "a hidden group";
     in.visibleToAll = false;
-    in.ownerId = getFromCache("Administrators").getGroupUUID().get();
+    in.ownerId = adminGroupUuid().get();
     gApi.groups().create(in);
 
     setApiUser(user);
@@ -668,7 +759,7 @@
     assertThat(groups).containsKey(group);
     assertThat(groups).containsKey(otherGroup);
 
-    groups = gApi.groups().list().withSubstring("foo").getAsMap();
+    groups = gApi.groups().list().withSubstring("non-existing-substring").getAsMap();
     assertThat(groups).isEmpty();
   }
 
@@ -690,7 +781,7 @@
 
   @Test
   public void allGroupInfoFieldsSetCorrectly() throws Exception {
-    InternalGroup adminGroup = getFromCache("Administrators");
+    InternalGroup adminGroup = adminGroup();
     Map<String, GroupInfo> groups = gApi.groups().list().addGroup(adminGroup.getName()).getAsMap();
     assertThat(groups).hasSize(1);
     assertThat(groups).containsKey("Administrators");
@@ -699,7 +790,6 @@
 
   @Test
   public void getAuditLog() throws Exception {
-    assume().that(cfg.getBoolean("user", null, "readGroupsFromNoteDb", false)).isFalse();
     GroupApi g = gApi.groups().create(name("group"));
     List<? extends GroupAuditEventInfo> auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(1);
@@ -727,10 +817,22 @@
     assertThat(auditEvents).hasSize(5);
     assertAuditEvent(auditEvents.get(0), Type.REMOVE_GROUP, admin.id, otherGroup);
 
+    // Add a removed member back again.
+    g.addMembers(user.username);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(6);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id, user.id);
+
+    // Add a removed group back again.
+    g.addGroups(otherGroup);
+    auditEvents = g.auditLog();
+    assertThat(auditEvents).hasSize(7);
+    assertAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id, otherGroup);
+
     Timestamp lastDate = null;
     for (GroupAuditEventInfo auditEvent : auditEvents) {
       if (lastDate != null) {
-        assertThat(lastDate).isGreaterThan(auditEvent.date);
+        assertThat(lastDate).isAtLeast(auditEvent.date);
       }
       lastDate = auditEvent.date;
     }
@@ -764,44 +866,57 @@
 
   @Test
   public void pushToGroupBranchIsRejectedForAllUsersRepo() throws Exception {
-    pushToGroupBranch(allUsers, "Not allowed to create group branch.", "group update not allowed");
+    String groupRef =
+        RefNames.refsGroups(new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+    assertPushToGroupBranch(allUsers, groupRef, !groupsInNoteDb(), "group update not allowed");
   }
 
   @Test
-  public void pushToGroupBranchForNonAllUsersRepo() throws Exception {
-    pushToGroupBranch(project, null, null);
+  public void pushToGroupNamesBranchIsRejectedForAllUsersRepo() throws Exception {
+    assume().that(groupsInNoteDb()).isTrue(); // branch only exists when groups are in NoteDb
+    // refs/meta/group-names isn't usually available for fetch, so grant ACCESS_DATABASE
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    assertPushToGroupBranch(allUsers, RefNames.REFS_GROUPNAMES, false, "group update not allowed");
   }
 
-  private void pushToGroupBranch(
-      Project.NameKey project, String expectedErrorOnCreate, String expectedErrorOnUpdate)
+  @Test
+  public void pushToGroupsBranchForNonAllUsersRepo() throws Exception {
+    assertCreateGroupBranch(project, null);
+    String groupRef =
+        RefNames.refsGroups(new AccountGroup.UUID(gApi.groups().create(name("foo")).get().id));
+    assertPushToGroupBranch(project, groupRef, true, null);
+  }
+
+  @Test
+  public void pushToGroupNamesBranchForNonAllUsersRepo() throws Exception {
+    assertPushToGroupBranch(project, RefNames.REFS_GROUPNAMES, true, null);
+  }
+
+  private void assertPushToGroupBranch(
+      Project.NameKey project, String groupRefName, boolean createRef, String expectedErrorOnUpdate)
       throws Exception {
     grant(project, RefNames.REFS_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
     grant(project, RefNames.REFS_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
+    grant(project, RefNames.REFS_GROUPNAMES, Permission.PUSH, false, REGISTERED_USERS);
 
     TestRepository<InMemoryRepository> repo = cloneProject(project);
 
-    // create new branch
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                db, admin.getIdent(), repo, "Update group config", "group.config", "some content")
-            .setParents(ImmutableList.of())
-            .to(RefNames.REFS_GROUPS + name("foo"));
-    if (expectedErrorOnCreate != null) {
-      r.assertErrorStatus(expectedErrorOnCreate);
-    } else {
-      r.assertOkStatus();
+    if (createRef) {
+      createGroupBranch(project, groupRefName);
     }
 
     // update existing branch
-    String groupRefName = RefNames.REFS_GROUPS + name("bar");
-    createGroupBranch(project, groupRefName);
     fetch(repo, groupRefName + ":groupRef");
     repo.reset("groupRef");
-    r =
+    PushOneCommit.Result r =
         pushFactory
             .create(
-                db, admin.getIdent(), repo, "Update group config", "group.config", "some content")
+                db,
+                admin.getIdent(),
+                repo,
+                "Update group config",
+                GroupConfig.GROUP_CONFIG_FILE,
+                "some content")
             .to(groupRefName);
     if (expectedErrorOnUpdate != null) {
       r.assertErrorStatus(expectedErrorOnUpdate);
@@ -810,6 +925,29 @@
     }
   }
 
+  private void assertCreateGroupBranch(Project.NameKey project, String expectedErrorOnCreate)
+      throws Exception {
+    grant(project, RefNames.REFS_GROUPS + "*", Permission.CREATE, false, REGISTERED_USERS);
+    grant(project, RefNames.REFS_GROUPS + "*", Permission.PUSH, false, REGISTERED_USERS);
+    TestRepository<InMemoryRepository> repo = cloneProject(project);
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                db,
+                admin.getIdent(),
+                repo,
+                "Update group config",
+                GroupConfig.GROUP_CONFIG_FILE,
+                "some content")
+            .setParents(ImmutableList.of())
+            .to(RefNames.REFS_GROUPS + name("bar"));
+    if (expectedErrorOnCreate != null) {
+      r.assertErrorStatus(expectedErrorOnCreate);
+    } else {
+      r.assertOkStatus();
+    }
+  }
+
   @Test
   public void pushToGroupBranchForReviewForAllUsersRepoIsRejectedOnSubmit() throws Exception {
     pushToGroupBranchForReviewAndSubmit(allUsers, "group update not allowed");
@@ -867,12 +1005,7 @@
     assume().that(groupsInNoteDb()).isTrue();
 
     grant(allUsers, RefNames.REFS_GROUPS + "*", Permission.DELETE, true, REGISTERED_USERS);
-
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    assertThat(adminGroup).isNotNull();
-    String groupRef = RefNames.refsGroups(adminGroup.getGroupUUID());
-
+    String groupRef = RefNames.refsGroups(adminGroupUuid());
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     PushResult r = deleteRef(allUsersRepo, groupRef);
     RemoteRefUpdate refUpdate = r.getRemoteUpdate(groupRef);
@@ -884,6 +1017,103 @@
     }
   }
 
+  @Test
+  public void defaultPermissionsOnGroupBranches() throws Exception {
+    assertPermissions(
+        allUsers, groupRef(REGISTERED_USERS), RefNames.REFS_GROUPS + "*", true, Permission.READ);
+  }
+
+  @Test
+  @Sandboxed
+  public void blockReviewDbUpdatesOnGroupCreation() throws Exception {
+    assume().that(groupsInNoteDb()).isFalse();
+    cfg.setBoolean("user", null, "blockReviewDbGroupUpdates", true);
+    try {
+      gApi.groups().create(name("foo"));
+      fail("Expected RestApiException: Updates to groups in ReviewDb are blocked");
+    } catch (RestApiException e) {
+      assertWriteGroupToReviewDbBlockedException(e);
+    }
+  }
+
+  @Test
+  @Sandboxed
+  public void blockReviewDbUpdatesOnGroupUpdate() throws Exception {
+    assume().that(groupsInNoteDb()).isFalse();
+    String group1 = gApi.groups().create(name("foo")).get().id;
+    String group2 = gApi.groups().create(name("bar")).get().id;
+    cfg.setBoolean("user", null, "blockReviewDbGroupUpdates", true);
+    try {
+      gApi.groups().id(group1).addGroups(group2);
+      fail("Expected RestApiException: Updates to groups in ReviewDb are blocked");
+    } catch (RestApiException e) {
+      assertWriteGroupToReviewDbBlockedException(e);
+    }
+  }
+
+  private void assertWriteGroupToReviewDbBlockedException(Exception e) throws Exception {
+    Throwable t = Throwables.getRootCause(e);
+    assertThat(t).isInstanceOf(OrmException.class);
+    assertThat(t.getMessage()).isEqualTo("Updates to groups in ReviewDb are blocked");
+  }
+
+  @Test
+  public void stalenessChecker() throws Exception {
+    assume().that(readGroupsFromNoteDb()).isTrue();
+
+    // Newly created group is not stale
+    GroupInfo groupInfo = gApi.groups().create(name("foo")).get();
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(groupInfo.id);
+    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+
+    // Manual update makes index document stale
+    String groupRef = RefNames.refsGroups(groupUuid);
+    try (Repository repo = repoManager.openRepository(allUsers);
+        ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(groupRef).getObjectId());
+
+      PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.nowTs());
+      CommitBuilder cb = new CommitBuilder();
+      cb.setTreeId(commit.getTree());
+      cb.setCommitter(ident);
+      cb.setAuthor(ident);
+      cb.setMessage(commit.getFullMessage());
+      ObjectId emptyCommit = oi.insert(cb);
+      oi.flush();
+
+      RefUpdate updateRef = repo.updateRef(groupRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(emptyCommit);
+      assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleGroupAndReindex(groupUuid);
+
+    // Manually delete group
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(repo.exactRef(groupRef).getObjectId());
+      RefUpdate updateRef = repo.updateRef(groupRef);
+      updateRef.setExpectedOldObjectId(commit.toObjectId());
+      updateRef.setNewObjectId(ObjectId.zeroId());
+      updateRef.setForceUpdate(true);
+      assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+    }
+    assertStaleGroupAndReindex(groupUuid);
+  }
+
+  private void assertStaleGroupAndReindex(AccountGroup.UUID groupUuid) throws IOException {
+    // Evict group from cache to be sure that we use the index state for staleness checks. This has
+    // to happen directly on the groupsByUUID cache because GroupsCacheImpl triggers a reindex for
+    // the group.
+    groupsByUUIDCache.invalidate(groupUuid.get());
+    assertThat(stalenessChecker.isStale(groupUuid)).isTrue();
+
+    // Reindex fixes staleness
+    groupIndexer.index(groupUuid);
+    assertThat(stalenessChecker.isStale(groupUuid)).isFalse();
+  }
+
   private void pushToGroupBranchForReviewAndSubmit(Project.NameKey project, String expectedError)
       throws Exception {
     grantLabel(
@@ -988,10 +1218,6 @@
     assertThat(gApi.groups().id(group).includedGroups()).isEmpty();
   }
 
-  private InternalGroup getFromCache(String name) throws Exception {
-    return groupCache.get(new AccountGroup.NameKey(name)).orElse(null);
-  }
-
   private void assertBadRequest(ListRequest req) throws Exception {
     try {
       req.get();
@@ -1002,7 +1228,10 @@
   }
 
   private boolean groupsInNoteDb() {
-    return cfg.getBoolean("user", "writeGroupsToNoteDb", false)
-        && cfg.getBoolean("user", "readGroupsFromNoteDb", false);
+    return cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, false);
+  }
+
+  private boolean readGroupsFromNoteDb() {
+    return groupsInNoteDb() && cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, false);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 75b7466..384dd7d 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
@@ -46,8 +45,7 @@
     normalProject = createProject("normal");
     secretProject = createProject("secret");
     secretRefProject = createProject("secretRef");
-    privilegedGroup =
-        groupCache.get(new AccountGroup.NameKey(createGroup("privilegedGroup"))).orElse(null);
+    privilegedGroup = group(createGroup("privilegedGroup"));
 
     privilegedUser = accountCreator.create("privilegedUser", "snowden@nsa.gov", "Ed Snowden");
     gApi.groups().id(privilegedGroup.getGroupUUID().get()).addMembers(privilegedUser.username);
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index a6dcc62..12fde07 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -17,25 +17,34 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.base.Predicates;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Patch;
 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.server.Sequences;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.git.ProjectConfig;
@@ -45,14 +54,16 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.testing.Util;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.testing.NoteDbMode;
 import com.google.gerrit.testing.TestChanges;
 import com.google.inject.Inject;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Predicate;
 import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.LsRemoteCommand;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -70,6 +81,7 @@
   @Inject private AllUsersName allUsersName;
 
   private AccountGroup.UUID admins;
+  private AccountGroup.UUID nonInteractiveUsers;
 
   private ChangeData c1;
   private ChangeData c2;
@@ -82,20 +94,29 @@
 
   @Before
   public void setUp() throws Exception {
-    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID();
+    admins = adminGroupUuid();
+    nonInteractiveUsers = groupUuid("Non-Interactive Users");
     setUpPermissions();
     setUpChanges();
   }
 
   private void setUpPermissions() throws Exception {
-    // Remove read permissions for all users besides admin. This method is
-    // idempotent, so is safe to call on every test setup.
+    // Remove read permissions for all users besides admin. This method is idempotent, so is safe
+    // to call on every test setup.
     ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
     for (AccessSection sec : pc.getAccessSections()) {
       sec.removePermission(Permission.READ);
     }
     Util.allow(pc, Permission.READ, admins, "refs/*");
     saveProjectConfig(allProjects, pc);
+
+    // Remove all read permissions on All-Users. This method is idempotent, so is safe to call on
+    // every test setup.
+    pc = projectCache.checkedGet(allUsers).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    saveProjectConfig(allUsers, pc);
   }
 
   private static String changeRefPrefix(Change.Id id) {
@@ -447,22 +468,123 @@
   }
 
   @Test
+  public void advertisedReferencesDontShowUserBranchWithoutRead() throws Exception {
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getUserRefs(git)).isEmpty();
+    }
+  }
+
+  @Test
+  public void advertisedReferencesOmitUserBranchesOfOtherUsers() throws Exception {
+    allow(allUsersName, RefNames.REFS_USERS + "*", Permission.READ, REGISTERED_USERS);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getUserRefs(git))
+          .containsExactly(RefNames.REFS_USERS_SELF, RefNames.refsUsers(user.id));
+    }
+  }
+
+  @Test
+  public void advertisedReferencesIncludeAllUserBranchesWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    try {
+      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+      try (Git git = userTestRepository.git()) {
+        assertThat(getUserRefs(git))
+            .containsExactly(
+                RefNames.REFS_USERS_SELF,
+                RefNames.refsUsers(user.id),
+                RefNames.refsUsers(admin.id));
+      }
+    } finally {
+      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    }
+  }
+
+  @Test
+  @Sandboxed
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesDontShowGroupBranchToOwnerWithoutRead() throws Exception {
+    createSelfOwnedGroup("Foos", user);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getGroupRefs(git)).isEmpty();
+    }
+  }
+
+  @Test
+  @Sandboxed
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesOmitGroupBranchesOfNonOwnedGroups() throws Exception {
+    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
+    AccountGroup.UUID users = createGroup("Users", admins, user);
+    AccountGroup.UUID foos = createGroup("Foos", users);
+    AccountGroup.UUID bars = createSelfOwnedGroup("Bars", user);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getGroupRefs(git))
+          .containsExactly(RefNames.refsGroups(foos), RefNames.refsGroups(bars));
+    }
+  }
+
+  @Test
+  @Sandboxed
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesIncludeAllGroupBranchesWithAccessDatabase() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    AccountGroup.UUID users = createGroup("Users", admins);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getGroupRefs(git))
+          .containsExactly(
+              RefNames.refsGroups(admins),
+              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.refsGroups(users));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesIncludeAllGroupBranchesForAdmins() throws Exception {
+    allow(allUsersName, RefNames.REFS_GROUPS + "*", Permission.READ, REGISTERED_USERS);
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    try {
+      AccountGroup.UUID users = createGroup("Users", admins);
+      TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+      try (Git git = userTestRepository.git()) {
+        assertThat(getGroupRefs(git))
+            .containsExactly(
+                RefNames.refsGroups(admins),
+                RefNames.refsGroups(nonInteractiveUsers),
+                RefNames.refsGroups(users));
+      }
+    } finally {
+      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ADMINISTRATE_SERVER);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void advertisedReferencesOmitNoteDbNotesBranches() throws Exception {
+    allow(allUsersName, RefNames.REFS + "*", Permission.READ, REGISTERED_USERS);
+    TestRepository<?> userTestRepository = cloneProject(allUsers, user);
+    try (Git git = userTestRepository.git()) {
+      assertThat(getRefs(git)).containsNoneOf(RefNames.REFS_EXTERNAL_IDS, RefNames.REFS_GROUPNAMES);
+    }
+  }
+
+  @Test
   public void advertisedReferencesOmitPrivateChangesOfOtherUsers() throws Exception {
     allow("refs/heads/master", Permission.READ, REGISTERED_USERS);
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      LsRemoteCommand lsRemoteCommand = git.lsRemote();
       String change3RefName = c3.currentPatchSet().getRefName();
-
-      List<String> initialRefNames =
-          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
-      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(c3.getId().get()).setPrivate(true, null);
-
-      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
-      assertThat(refNames).doesNotContain(change3RefName);
+      assertThat(getRefs(git)).doesNotContain(change3RefName);
     }
   }
 
@@ -472,21 +594,16 @@
 
     TestRepository<?> userTestRepository = cloneProject(project, user);
     try (Git git = userTestRepository.git()) {
-      LsRemoteCommand lsRemoteCommand = git.lsRemote();
       String change3RefName = c3.currentPatchSet().getRefName();
-
-      List<String> initialRefNames =
-          lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
-      assertWithMessage("Precondition violated").that(initialRefNames).contains(change3RefName);
+      assertWithMessage("Precondition violated").that(getRefs(git)).contains(change3RefName);
 
       gApi.changes().id(c3.getId().get()).setPrivate(true, null);
-
-      List<String> refNames = lsRemoteCommand.call().stream().map(Ref::getName).collect(toList());
-      assertThat(refNames).contains(change3RefName);
+      assertThat(getRefs(git)).contains(change3RefName);
     }
   }
 
   @Test
+  @Sandboxed
   public void advertisedReferencesOmitDraftCommentRefsOfOtherUsers() throws Exception {
     assume().that(notesMigration.commitChangeWrites()).isTrue();
 
@@ -509,6 +626,7 @@
   }
 
   @Test
+  @Sandboxed
   public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
     assume().that(notesMigration.commitChangeWrites()).isTrue();
 
@@ -526,6 +644,57 @@
     assertThat(lsRemote(allUsersName, accountCreator.user2())).doesNotContain(starredChangesRef);
   }
 
+  @Test
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
+  public void hideMetadata() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    try {
+      // create change
+      TestRepository<?> allUsersRepo = cloneProject(allUsers);
+      fetch(allUsersRepo, RefNames.REFS_USERS_SELF + ":userRef");
+      allUsersRepo.reset("userRef");
+      PushOneCommit.Result mr =
+          pushFactory
+              .create(db, admin.getIdent(), allUsersRepo)
+              .to("refs/for/" + RefNames.REFS_USERS_SELF);
+      mr.assertOkStatus();
+
+      List<String> expectedNonMetaRefs =
+          ImmutableList.of(
+              RefNames.REFS_USERS_SELF,
+              RefNames.refsUsers(admin.id),
+              RefNames.refsUsers(user.id),
+              RefNames.REFS_EXTERNAL_IDS,
+              RefNames.REFS_GROUPNAMES,
+              RefNames.refsGroups(admins),
+              RefNames.refsGroups(nonInteractiveUsers),
+              RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS,
+              RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS,
+              RefNames.REFS_CONFIG);
+
+      List<String> expectedMetaRefs =
+          new ArrayList<>(ImmutableList.of(mr.getPatchSetId().toRefName()));
+      if (NoteDbMode.get() != NoteDbMode.OFF) {
+        expectedMetaRefs.add(changeRefPrefix(mr.getChange().getId()) + "meta");
+      }
+
+      List<String> expectedAllRefs = new ArrayList<>(expectedNonMetaRefs);
+      expectedAllRefs.addAll(expectedMetaRefs);
+
+      try (Repository repo = repoManager.openRepository(allUsers)) {
+        Map<String, Ref> all = repo.getAllRefs();
+
+        VisibleRefFilter filter = refFilterFactory.create(projectCache.get(allUsers), repo);
+        assertThat(filter.filter(all, false).keySet()).containsExactlyElementsIn(expectedAllRefs);
+
+        assertThat(filter.setShowMetadata(false).filter(all, false).keySet())
+            .containsExactlyElementsIn(expectedNonMetaRefs);
+      }
+    } finally {
+      removeGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+    }
+  }
+
   private List<String> lsRemote(Project.NameKey p, TestAccount a) throws Exception {
     TestRepository<?> testRepository = cloneProject(p, a);
     try (Git git = testRepository.git()) {
@@ -533,6 +702,22 @@
     }
   }
 
+  private List<String> getRefs(Git git) throws Exception {
+    return getRefs(git, Predicates.alwaysTrue());
+  }
+
+  private List<String> getUserRefs(Git git) throws Exception {
+    return getRefs(git, RefNames::isRefsUsers);
+  }
+
+  private List<String> getGroupRefs(Git git) throws Exception {
+    return getRefs(git, RefNames::isRefsGroups);
+  }
+
+  private List<String> getRefs(Git git, Predicate<String> predicate) throws Exception {
+    return git.lsRemote().call().stream().map(Ref::getName).filter(predicate).collect(toList());
+  }
+
   /**
    * Assert that refs seen by a non-admin user match expected.
    *
@@ -590,4 +775,20 @@
     assertWithMessage("%s not found in %s", psId, cd.patchSets()).that(ps).isNotNull();
     return ObjectId.fromString(ps.getRevision().get());
   }
+
+  private AccountGroup.UUID createSelfOwnedGroup(String name, TestAccount... members)
+      throws RestApiException {
+    return createGroup(name, null, members);
+  }
+
+  private AccountGroup.UUID createGroup(
+      String name, @Nullable AccountGroup.UUID ownerGroup, TestAccount... members)
+      throws RestApiException {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name(name);
+    groupInput.ownerId = ownerGroup != null ? ownerGroup.get() : null;
+    groupInput.members =
+        Arrays.stream(members).map(m -> String.valueOf(m.id.get())).collect(toList());
+    return new AccountGroup.UUID(gApi.groups().create(groupInput).get().id);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 4cc15d6..15e6a35 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -273,6 +273,8 @@
 
   @Test
   public void pushToExternalIdsBranch() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
@@ -296,6 +298,8 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithoutAccountId() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
@@ -312,6 +316,8 @@
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithKeyThatDoesntMatchTheNoteId()
       throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
@@ -327,6 +333,8 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithInvalidConfig() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
@@ -342,6 +350,8 @@
 
   @Test
   public void pushToExternalIdsBranchRejectsExternalIdWithEmptyNote() throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
@@ -380,6 +390,8 @@
 
   private void testPushToExternalIdsBranchRejectsInvalidExternalId(ExternalId invalidExtId)
       throws Exception {
+    allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
+
     TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
     fetch(allUsersRepo, RefNames.REFS_EXTERNAL_IDS + ":" + RefNames.REFS_EXTERNAL_IDS);
     allUsersRepo.reset(RefNames.REFS_EXTERNAL_IDS);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index cb0d768ef..8ca520d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -56,9 +56,9 @@
 
   @Before
   public void setUp() throws Exception {
-    group1 = group("users1");
-    group2 = group("users2");
-    group3 = group("users3");
+    group1 = newGroup("users1");
+    group2 = newGroup("users2");
+    group3 = newGroup("users3");
 
     user1 = user("user1", "First1 Last1", group1);
     user2 = user("user2", "First2 Last2", group2);
@@ -235,8 +235,8 @@
   @GerritConfig(name = "addreviewer.maxAllowed", value = "2")
   @GerritConfig(name = "addreviewer.maxWithoutConfirmation", value = "1")
   public void suggestReviewersGroupSizeConsiderations() throws Exception {
-    InternalGroup largeGroup = group("large");
-    InternalGroup mediumGroup = group("medium");
+    InternalGroup largeGroup = newGroup("large");
+    InternalGroup mediumGroup = newGroup("medium");
 
     // Both groups have Administrator as a member. Add two users to large
     // group to push it past maxAllowed, and one to medium group to push it
@@ -425,9 +425,9 @@
     return gApi.changes().id(changeId).suggestReviewers(query).withLimit(n).get();
   }
 
-  private InternalGroup group(String name) throws Exception {
+  private InternalGroup newGroup(String name) throws Exception {
     GroupInfo group = createGroupFactory.create(name(name)).apply(TopLevelResource.INSTANCE, null);
-    return groupCache.get(new AccountGroup.UUID(group.id)).orElse(null);
+    return group(new AccountGroup.UUID(group.id));
   }
 
   private TestAccount user(String name, String fullName, String emailName, InternalGroup... groups)
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
index b586ab2..bc27fff 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -15,25 +15,20 @@
 package com.google.gerrit.acceptance.rest.config;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
-import com.google.gerrit.server.group.InternalGroup;
 import org.junit.Test;
 
 public class FlushCacheIT extends AbstractDaemonTest {
 
   @Test
   public void flushCache() throws Exception {
-    InternalGroup group = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    assertWithMessage("Precondition: The group 'Administrators' was loaded by the group cache")
-        .that(group)
-        .isNotNull();
+    // access the admin group once so that it is loaded into the group cache
+    adminGroup();
 
     RestResponse r = adminRestSession.get("/config/server/caches/groups_byname");
     CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 6002acd..241e695 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -36,12 +36,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import java.util.HashMap;
 import java.util.Map;
@@ -396,14 +394,11 @@
 
   @Test
   public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
 
     PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroup.getGroupUUID().get(), null);
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
     accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
 
     accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
@@ -426,14 +421,11 @@
 
   @Test
   public void removeGlobalCapabilityAsAdmin() throws Exception {
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-
     ProjectAccessInput accessInput = newProjectAccessInput();
     AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
 
     PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroup.getGroupUUID().get(), null);
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
     accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
 
     // Add and validate first as removing existing privileges such as
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index b150df3..48dc994 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -32,7 +32,6 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.group.InternalGroup;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -103,16 +102,13 @@
   }
 
   @Test
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void createGroupBranch_Conflict() throws Exception {
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    assertThat(adminGroup).isNotNull();
     assertCreateFails(
         new Branch.NameKey(allUsers, RefNames.refsGroups(new AccountGroup.UUID("foo"))),
-        RefNames.refsGroups(adminGroup.getGroupUUID()),
+        RefNames.refsGroups(adminGroupUuid()),
         ResourceConflictException.class,
         "Not allowed to create group branch.");
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
index b47b51a..f90a73b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -326,10 +326,6 @@
     }
   }
 
-  private AccountGroup.UUID groupUuid(String groupName) {
-    return groupCache.get(new AccountGroup.NameKey(groupName)).orElse(null).getGroupUUID();
-  }
-
   private void assertHead(String projectName, String expectedRef) throws Exception {
     try (Repository repo = repoManager.openRepository(new Project.NameKey(projectName))) {
       assertThat(repo.exactRef(Constants.HEAD).getTarget().getName()).isEqualTo(expectedRef);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 3abc581..5e1b0bf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -29,10 +29,8 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.group.InternalGroup;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -140,18 +138,14 @@
   }
 
   @Test
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void deleteGroupBranch_Conflict() throws Exception {
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.CREATE, REGISTERED_USERS);
     allow(allUsers, RefNames.REFS_GROUPS + "*", Permission.PUSH, REGISTERED_USERS);
 
-    InternalGroup adminGroup =
-        groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
-    assertThat(adminGroup).isNotNull();
-
     exception.expect(ResourceConflictException.class);
     exception.expectMessage("Not allowed to delete group branch.");
-    branch(new Branch.NameKey(allUsers, RefNames.refsGroups(adminGroup.getGroupUUID()))).delete();
+    branch(new Branch.NameKey(allUsers, RefNames.refsGroups(adminGroupUuid()))).delete();
   }
 
   private void blockForcePush() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index 2ade7f6..a9ea42e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -217,6 +217,11 @@
     assertThatNameList(gApi.projects().list().withType(FilterType.ALL).get())
         .containsExactly(allProjects, allUsers, project)
         .inOrder();
+
+    // "All" boolean option causes hidden projects to be included
+    assertThatNameList(gApi.projects().list().withAll().get())
+        .containsExactly(allProjects, allUsers, project, hidden)
+        .inOrder();
   }
 
   private void assertBadRequest(ListRequest req) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
index adc0f20..90f20ef 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbPrimaryIT.java
@@ -454,15 +454,6 @@
   }
 
   @Test
-  public void rebuildReviewDbRequiresNoteDbPrimary() throws Exception {
-    Change.Id id = createChange().getChange().getId();
-
-    exception.expect(OrmException.class);
-    exception.expectMessage("primary storage of " + id + " is REVIEW_DB");
-    rebuilderWrapper.rebuildReviewDb(db, project, id);
-  }
-
-  @Test
   public void migrateBackToReviewDbPrimary() throws Exception {
     Change c = createChange().getChange().change();
     Change.Id id = c.getId();
diff --git a/javatests/com/google/gerrit/common/BUILD b/javatests/com/google/gerrit/common/BUILD
index ca04a4a..50889ba 100644
--- a/javatests/com/google/gerrit/common/BUILD
+++ b/javatests/com/google/gerrit/common/BUILD
@@ -24,6 +24,7 @@
     srcs = SERVER_TEST_SRCS,
     deps = [
         "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/common:version",
         "//java/com/google/gerrit/launcher",
         "//lib:guava",
         "//lib:truth",
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
index 3df89d9..9bb5246 100644
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ b/javatests/com/google/gerrit/elasticsearch/BUILD
@@ -40,7 +40,7 @@
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//javatests/com/google/gerrit/server:abstract_query_tests",
+        "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests" % name,
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/jgit/org.eclipse.jgit.junit:junit",
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index c37a8ec..d1d6611 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -110,7 +110,7 @@
   }
 
   static void deleteAllIndexes(ElasticNodeInfo nodeInfo) {
-    nodeInfo.node.client().admin().indices().prepareDelete("_all").execute();
+    nodeInfo.node.client().admin().indices().prepareDelete("_all").execute().actionGet();
   }
 
   static class NodeInfo {
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index a7f2e09..2eaf4f5 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -1,30 +1,5 @@
 load("//tools/bzl:junit.bzl", "junit_tests")
 
-TESTUTIL_DEPS = [
-    "//java/com/google/gerrit/common:annotations",
-    "//java/com/google/gerrit/common:server",
-    "//java/com/google/gerrit/extensions:api",
-    "//java/com/google/gerrit/gpg",
-    "//java/com/google/gerrit/lifecycle",
-    "//java/com/google/gerrit/metrics",
-    "//java/com/google/gerrit/reviewdb:server",
-    "//java/com/google/gerrit/server",
-    "//java/com/google/gerrit/server:module",
-    "//java/com/google/gerrit/server/cache/h2",
-    "//java/com/google/gerrit/index",
-    "//java/com/google/gerrit/lucene",
-    "//lib:gwtorm",
-    "//lib:h2",
-    "//lib:truth",
-    "//lib/guice:guice",
-    "//lib/guice:guice-servlet",
-    "//lib/jgit/org.eclipse.jgit:jgit",
-    "//lib/jgit/org.eclipse.jgit.junit:junit",
-    "//lib/log:api",
-    "//lib/log:impl_log4j",
-    "//lib/log:log4j",
-]
-
 CUSTOM_TRUTH_SUBJECTS = glob([
     "**/*Subject.java",
 ])
@@ -41,109 +16,42 @@
     ],
 )
 
-PROLOG_TEST_CASE = [
-    "rules/PrologTestCase.java",
-]
-
-PROLOG_TESTS = glob(
-    ["rules/**/*.java"],
-    exclude = PROLOG_TEST_CASE,
-)
-
-java_library(
-    name = "prolog_test_case",
-    testonly = 1,
-    srcs = PROLOG_TEST_CASE,
-    deps = [
-        "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
-        "//lib:junit",
-        "//lib:truth",
-        "//lib/guice",
-        "//lib/prolog:runtime",
-    ],
-)
-
-junit_tests(
-    name = "prolog_tests",
-    srcs = PROLOG_TESTS,
-    resource_strip_prefix = "prologtests",
-    resources = ["//prologtests:gerrit_common_test"],
-    deps = TESTUTIL_DEPS + [
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//java/com/google/gerrit/server/project/testing:project-test-util",
-        "//prolog:gerrit-prolog-common",
-        ":prolog_test_case",
-        "//lib/prolog:runtime",
-    ],
-)
-
-ABSTRACT_QUERY_TESTS = glob(
-    ["query/**/AbstractQuery*Test.java"],
-)
-
-LUCENE_QUERY_TESTS = {i: "query/" + i + "/LuceneQuery" + i.capitalize() + "sTest.java" for i in [
-    "account",
-    "change",
-    "group",
-    "project",
-]}
-
-java_library(
-    name = "abstract_query_tests",
-    testonly = 1,
-    srcs = ABSTRACT_QUERY_TESTS,
-    visibility = ["//visibility:public"],
-    deps = TESTUTIL_DEPS + [
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//prolog:gerrit-prolog-common",
-    ],
-)
-
-[junit_tests(
-    name = "lucene_query_%ss_test" % name,
-    size = "large",
-    srcs = [src],
-    visibility = ["//visibility:public"],
-    deps = TESTUTIL_DEPS + [
-        ":abstract_query_tests",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-    ],
-) for name, src in LUCENE_QUERY_TESTS.items()]
-
 junit_tests(
     name = "server_tests",
     size = "large",
     srcs = glob(
         ["**/*.java"],
-        exclude = CUSTOM_TRUTH_SUBJECTS + PROLOG_TESTS + PROLOG_TEST_CASE + ABSTRACT_QUERY_TESTS + LUCENE_QUERY_TESTS.values(),
+        exclude = CUSTOM_TRUTH_SUBJECTS,
     ),
     resource_strip_prefix = "resources",
     resources = ["//resources/com/google/gerrit/server"],
     visibility = ["//visibility:public"],
-    deps = TESTUTIL_DEPS + [
+    runtime_deps = [
+        "//lib/bouncycastle:bcprov",
+    ],
+    deps = [
         ":custom-truth-subjects",
-        "//java/com/google/gerrit/extensions/client/testing:client-test-util",
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/metrics",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/org/eclipse/jgit:server",
-        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
-        "//lib:args4j",
         "//lib:grappa",
         "//lib:gson",
-        "//lib:guava",
         "//lib:guava-retrying",
-        "//lib:protobuf",
+        "//lib:gwtorm",
         "//lib:truth-java8-extension",
-        "//lib/bouncycastle:bcprov",
-        "//lib/bouncycastle:bcpkix",
-        "//lib/guice:guice-assistedinject",
-        "//lib/prolog:runtime",
         "//lib/commons:codec",
-        "//prolog:gerrit-prolog-common",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
     ],
 )
diff --git a/javatests/com/google/gerrit/server/account/GroupUUIDTest.java b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
new file mode 100644
index 0000000..3b72b08
--- /dev/null
+++ b/javatests/com/google/gerrit/server/account/GroupUUIDTest.java
@@ -0,0 +1,32 @@
+// 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.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.Test;
+
+public class GroupUUIDTest {
+  @Test
+  public void createdUuidsForSameInputShouldBeDifferent() {
+    String groupName = "Users";
+    PersonIdent personIdent = new PersonIdent("John", "john@example.com");
+    AccountGroup.UUID uuid1 = GroupUUID.make(groupName, personIdent);
+    AccountGroup.UUID uuid2 = GroupUUID.make(groupName, personIdent);
+    assertThat(uuid2).isNotEqualTo(uuid1);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/git/VersionedMetaDataTest.java b/javatests/com/google/gerrit/server/git/VersionedMetaDataTest.java
index a2b681d..d765c5e 100644
--- a/javatests/com/google/gerrit/server/git/VersionedMetaDataTest.java
+++ b/javatests/com/google/gerrit/server/git/VersionedMetaDataTest.java
@@ -190,9 +190,7 @@
     assertMyMetaData(d2.getRefName(), 0);
     assertThat(bru.getCommands().stream().map(ReceiveCommand::getRefName))
         .containsExactly("refs/meta/1", "refs/meta/2");
-    try (RevWalk rw = new RevWalk(repo)) {
-      RefUpdateUtil.executeChecked(bru, rw);
-    }
+    RefUpdateUtil.executeChecked(bru, repo);
 
     assertMyMetaData(d1.getRefName(), 4, "Increment conf.value by 1", "Increment conf.value by 3");
     assertMyMetaData(
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
new file mode 100644
index 0000000..70d5bf6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -0,0 +1,143 @@
+// 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.group.db;
+
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.sql.Timestamp;
+import java.util.TimeZone;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+
+@Ignore
+public class AbstractGroupTest extends GerritBaseTests {
+  protected static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+  protected static final String SERVER_ID = "server-id";
+  protected static final String SERVER_NAME = "Gerrit Server";
+  protected static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
+  protected static final int SERVER_ACCOUNT_NUMBER = 100000;
+  protected static final int USER_ACCOUNT_NUMBER = 100001;
+
+  protected AllUsersName allUsersName;
+  protected InMemoryRepositoryManager repoManager;
+  protected Repository allUsersRepo;
+  protected Account.Id serverAccountId;
+  protected PersonIdent serverIdent;
+  protected Account.Id userId;
+  protected PersonIdent userIdent;
+
+  @Before
+  public void abstractGroupTestSetUp() throws Exception {
+    allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+    repoManager = new InMemoryRepositoryManager();
+    allUsersRepo = repoManager.createRepository(allUsersName);
+    serverAccountId = new Account.Id(SERVER_ACCOUNT_NUMBER);
+    serverIdent = new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+    userId = new Account.Id(USER_ACCOUNT_NUMBER);
+    userIdent = newPersonIdent(userId, serverIdent);
+  }
+
+  @After
+  public void abstractGroupTestTearDown() throws Exception {
+    allUsersRepo.close();
+  }
+
+  protected Timestamp getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
+    try (RevWalk rw = new RevWalk(allUsersRepo)) {
+      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
+      return ref == null
+          ? null
+          : new Timestamp(rw.parseCommit(ref.getObjectId()).getAuthorIdent().getWhen().getTime());
+    }
+  }
+
+  protected void assertTipCommit(AccountGroup.UUID uuid, String expectedMessage) throws Exception {
+    try (RevWalk rw = new RevWalk(allUsersRepo)) {
+      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
+      assertCommit(
+          CommitUtil.toCommitInfo(rw.parseCommit(ref.getObjectId()), rw),
+          expectedMessage,
+          getAccountName(userId),
+          getAccountEmail(userId));
+    }
+  }
+
+  protected static void assertServerCommit(CommitInfo commitInfo, String expectedMessage) {
+    assertCommit(commitInfo, expectedMessage, SERVER_NAME, SERVER_EMAIL);
+  }
+
+  protected static void assertCommit(
+      CommitInfo commitInfo, String expectedMessage, String expectedName, String expectedEmail) {
+    assertThat(commitInfo).message().isEqualTo(expectedMessage);
+    assertThat(commitInfo).author().name().isEqualTo(expectedName);
+    assertThat(commitInfo).author().email().isEqualTo(expectedEmail);
+
+    // Committer should always be the server, regardless of author.
+    assertThat(commitInfo).committer().name().isEqualTo(SERVER_NAME);
+    assertThat(commitInfo).committer().email().isEqualTo(SERVER_EMAIL);
+    assertThat(commitInfo).committer().date().isEqualTo(commitInfo.author.date);
+    assertThat(commitInfo).committer().tz().isEqualTo(commitInfo.author.tz);
+  }
+
+  protected MetaDataUpdate createMetaDataUpdate(PersonIdent authorIdent) {
+    MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo);
+    md.getCommitBuilder().setAuthor(authorIdent);
+    md.getCommitBuilder().setCommitter(serverIdent); // Committer is always the server identity.
+    return md;
+  }
+
+  protected static PersonIdent newPersonIdent() {
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+  }
+
+  protected static PersonIdent newPersonIdent(Account.Id id, PersonIdent ident) {
+    return new PersonIdent(
+        getAccountName(id), getAccountEmail(id), ident.getWhen(), ident.getTimeZone());
+  }
+
+  protected static String getAccountNameEmail(Account.Id id) {
+    return String.format("%s <%s>", getAccountName(id), getAccountEmail(id));
+  }
+
+  protected static String getGroupName(AccountGroup.UUID uuid) {
+    return String.format("Group <%s>", uuid);
+  }
+
+  protected static String getAccountName(Account.Id id) {
+    return "Account " + id;
+  }
+
+  protected static String getAccountEmail(Account.Id id) {
+    return String.format("%s@%s", id, SERVER_ID);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
new file mode 100644
index 0000000..c985c46
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -0,0 +1,382 @@
+// 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.group.db;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.server.account.GroupUUID;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.group.InternalGroup;
+import java.sql.Timestamp;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link AuditLogReader}. */
+public final class AuditLogReaderTest extends AbstractGroupTest {
+
+  private AuditLogReader auditLogReader;
+
+  @Before
+  public void setUp() throws Exception {
+    auditLogReader = new AuditLogReader(SERVER_ID);
+  }
+
+  @Test
+  public void createGroupAsUserIdent() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    AccountGroupMemberAudit expAudit =
+        createExpMemberAudit(group.getId(), userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit);
+  }
+
+  @Test
+  public void createGroupAsServerIdent() throws Exception {
+    InternalGroup group = createGroup(1, "test-group", serverIdent, null);
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, group.getGroupUUID())).hasSize(0);
+  }
+
+  @Test
+  public void addAndRemoveMember() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    AccountGroupMemberAudit expAudit1 =
+        createExpMemberAudit(group.getId(), userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
+
+    // User adds account 100002 to the group.
+    Account.Id id = new Account.Id(100002);
+    addMembers(uuid, ImmutableSet.of(id));
+
+    AccountGroupMemberAudit expAudit2 =
+        createExpMemberAudit(group.getId(), id, userId, getTipTimestamp(uuid));
+    assertTipCommit(uuid, "Update group\n\nAdd: Account 100002 <100002@server-id>");
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2)
+        .inOrder();
+
+    // User removes account 100002 from the group.
+    removeMembers(uuid, ImmutableSet.of(id));
+    assertTipCommit(uuid, "Update group\n\nRemove: Account 100002 <100002@server-id>");
+
+    expAudit2.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2)
+        .inOrder();
+  }
+
+  @Test
+  public void addMultiMembers() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.Id groupId = group.getId();
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    AccountGroupMemberAudit expAudit1 =
+        createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expAudit1);
+
+    Account.Id id1 = new Account.Id(100002);
+    Account.Id id2 = new Account.Id(100003);
+    addMembers(uuid, ImmutableSet.of(id1, id2));
+
+    AccountGroupMemberAudit expAudit2 =
+        createExpMemberAudit(groupId, id1, userId, getTipTimestamp(uuid));
+    AccountGroupMemberAudit expAudit3 =
+        createExpMemberAudit(groupId, id2, userId, getTipTimestamp(uuid));
+
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + "Add: Account 100002 <100002@server-id>\n"
+            + "Add: Account 100003 <100003@server-id>");
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2, expAudit3)
+        .inOrder();
+  }
+
+  @Test
+  public void addAndRemoveSubgroups() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    InternalGroup subgroup = createGroupAsUser(2, "test-group-2");
+    AccountGroup.UUID subgroupUuid = subgroup.getGroupUUID();
+
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid));
+    assertTipCommit(uuid, String.format("Update group\n\nAdd-group: Group <%s>", subgroupUuid));
+
+    AccountGroupByIdAud expAudit =
+        createExpGroupAudit(group.getId(), subgroupUuid, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
+
+    removeSubgroups(uuid, ImmutableSet.of(subgroupUuid));
+    assertTipCommit(uuid, String.format("Update group\n\nRemove-group: Group <%s>", subgroupUuid));
+
+    expAudit.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid)).containsExactly(expAudit);
+  }
+
+  @Test
+  public void addMultiSubgroups() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.UUID uuid = group.getGroupUUID();
+
+    InternalGroup subgroup1 = createGroupAsUser(2, "test-group-2");
+    InternalGroup subgroup2 = createGroupAsUser(3, "test-group-3");
+    AccountGroup.UUID subgroupUuid1 = subgroup1.getGroupUUID();
+    AccountGroup.UUID subgroupUuid2 = subgroup2.getGroupUUID();
+
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid2));
+
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add-group: Group <%s>\n", subgroupUuid1)
+            + String.format("Add-group: Group <%s>", subgroupUuid2));
+
+    AccountGroupByIdAud expAudit1 =
+        createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
+    AccountGroupByIdAud expAudit2 =
+        createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expAudit1, expAudit2)
+        .inOrder();
+  }
+
+  @Test
+  public void addAndRemoveMembersAndSubgroups() throws Exception {
+    InternalGroup group = createGroupAsUser(1, "test-group");
+    AccountGroup.Id groupId = group.getId();
+    AccountGroup.UUID uuid = group.getGroupUUID();
+    AccountGroupMemberAudit expMemberAudit =
+        createExpMemberAudit(groupId, userId, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid)).containsExactly(expMemberAudit);
+
+    Account.Id id1 = new Account.Id(100002);
+    Account.Id id2 = new Account.Id(100003);
+    Account.Id id3 = new Account.Id(100004);
+    InternalGroup subgroup1 = createGroupAsUser(2, "test-group-2");
+    InternalGroup subgroup2 = createGroupAsUser(3, "test-group-3");
+    InternalGroup subgroup3 = createGroupAsUser(4, "test-group-4");
+    AccountGroup.UUID subgroupUuid1 = subgroup1.getGroupUUID();
+    AccountGroup.UUID subgroupUuid2 = subgroup2.getGroupUUID();
+    AccountGroup.UUID subgroupUuid3 = subgroup3.getGroupUUID();
+
+    // Add two accounts.
+    addMembers(uuid, ImmutableSet.of(id1, id2));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add: Account %s <%s@server-id>\n", id1, id1)
+            + String.format("Add: Account %s <%s@server-id>", id2, id2));
+    AccountGroupMemberAudit expMemberAudit1 =
+        createExpMemberAudit(groupId, id1, userId, getTipTimestamp(uuid));
+    AccountGroupMemberAudit expMemberAudit2 =
+        createExpMemberAudit(groupId, id2, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expMemberAudit, expMemberAudit1, expMemberAudit2)
+        .inOrder();
+
+    // Add one subgroup.
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
+    assertTipCommit(uuid, String.format("Update group\n\nAdd-group: Group <%s>", subgroupUuid1));
+    AccountGroupByIdAud expGroupAudit1 =
+        createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1);
+
+    // Remove one account.
+    removeMembers(uuid, ImmutableSet.of(id2));
+    assertTipCommit(
+        uuid, String.format("Update group\n\nRemove: Account %s <%s@server-id>", id2, id2));
+    expMemberAudit2.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(expMemberAudit, expMemberAudit1, expMemberAudit2)
+        .inOrder();
+
+    // Add two subgroups.
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid2, subgroupUuid3));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add-group: Group <%s>\n", subgroupUuid2)
+            + String.format("Add-group: Group <%s>", subgroupUuid3));
+    AccountGroupByIdAud expGroupAudit2 =
+        createExpGroupAudit(group.getId(), subgroupUuid2, userId, getTipTimestamp(uuid));
+    AccountGroupByIdAud expGroupAudit3 =
+        createExpGroupAudit(group.getId(), subgroupUuid3, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
+        .inOrder();
+
+    // Add two account, including a removed account.
+    addMembers(uuid, ImmutableSet.of(id2, id3));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Add: Account %s <%s@server-id>\n", id2, id2)
+            + String.format("Add: Account %s <%s@server-id>", id3, id3));
+    AccountGroupMemberAudit expMemberAudit4 =
+        createExpMemberAudit(groupId, id2, userId, getTipTimestamp(uuid));
+    AccountGroupMemberAudit expMemberAudit3 =
+        createExpMemberAudit(groupId, id3, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getMembersAudit(allUsersRepo, uuid))
+        .containsExactly(
+            expMemberAudit, expMemberAudit1, expMemberAudit2, expMemberAudit4, expMemberAudit3)
+        .inOrder();
+
+    // Remove two subgroups.
+    removeSubgroups(uuid, ImmutableSet.of(subgroupUuid1, subgroupUuid3));
+    assertTipCommit(
+        uuid,
+        "Update group\n"
+            + "\n"
+            + String.format("Remove-group: Group <%s>\n", subgroupUuid1)
+            + String.format("Remove-group: Group <%s>", subgroupUuid3));
+    expGroupAudit1.removed(userId, getTipTimestamp(uuid));
+    expGroupAudit3.removed(userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3)
+        .inOrder();
+
+    // Add back one removed subgroup.
+    addSubgroups(uuid, ImmutableSet.of(subgroupUuid1));
+    AccountGroupByIdAud expGroupAudit4 =
+        createExpGroupAudit(group.getId(), subgroupUuid1, userId, getTipTimestamp(uuid));
+    assertThat(auditLogReader.getSubgroupsAudit(allUsersRepo, uuid))
+        .containsExactly(expGroupAudit1, expGroupAudit2, expGroupAudit3, expGroupAudit4)
+        .inOrder();
+  }
+
+  private InternalGroup createGroupAsUser(int next, String groupName) throws Exception {
+    return createGroup(next, groupName, userIdent, userId);
+  }
+
+  private InternalGroup createGroup(
+      int next, String groupName, PersonIdent authorIdent, Account.Id authorId) throws Exception {
+    InternalGroupCreation groupCreation =
+        InternalGroupCreation.builder()
+            .setGroupUUID(GroupUUID.make(groupName, serverIdent))
+            .setNameKey(new AccountGroup.NameKey(groupName))
+            .setId(new AccountGroup.Id(next))
+            .setCreatedOn(TimeUtil.nowTs())
+            .build();
+    InternalGroupUpdate groupUpdate =
+        authorIdent.equals(serverIdent)
+            ? InternalGroupUpdate.builder().setDescription("Groups").build()
+            : InternalGroupUpdate.builder()
+                .setDescription("Groups")
+                .setMemberModification(members -> ImmutableSet.of(authorId))
+                .build();
+
+    GroupConfig groupConfig = GroupConfig.createForNewGroup(allUsersRepo, groupCreation);
+    groupConfig.setGroupUpdate(
+        groupUpdate, AbstractGroupTest::getAccountNameEmail, AbstractGroupTest::getGroupName);
+
+    RevCommit commit = groupConfig.commit(createMetaDataUpdate(authorIdent));
+    assertCreateGroup(authorIdent, commit);
+    return groupConfig
+        .getLoadedGroup()
+        .orElseThrow(() -> new IllegalStateException("create group failed"));
+  }
+
+  private void assertCreateGroup(PersonIdent authorIdent, RevCommit commit) throws Exception {
+    if (authorIdent.equals(serverIdent)) {
+      assertServerCommit(CommitUtil.toCommitInfo(commit), "Create group");
+    } else {
+      assertCommit(
+          CommitUtil.toCommitInfo(commit),
+          String.format("Create group\n\nAdd: Account %s <%s@%s>", userId, userId, SERVER_ID),
+          getAccountName(userId),
+          getAccountEmail(userId));
+    }
+  }
+
+  private InternalGroup updateGroup(AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate)
+      throws Exception {
+    GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersRepo, uuid);
+    groupConfig.setGroupUpdate(
+        groupUpdate, AbstractGroupTest::getAccountNameEmail, AbstractGroupTest::getGroupName);
+
+    groupConfig.commit(createMetaDataUpdate(userIdent));
+    return groupConfig
+        .getLoadedGroup()
+        .orElseThrow(() -> new IllegalStateException("updated group failed"));
+  }
+
+  private InternalGroup addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.union(memberIds, ids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private InternalGroup removeMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setMemberModification(memberIds -> Sets.difference(memberIds, ids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private InternalGroup addSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setSubgroupModification(memberIds -> Sets.union(memberIds, uuids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private InternalGroup removeSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
+      throws Exception {
+    InternalGroupUpdate update =
+        InternalGroupUpdate.builder()
+            .setSubgroupModification(memberIds -> Sets.difference(memberIds, uuids))
+            .build();
+    return updateGroup(groupUuid, update);
+  }
+
+  private AccountGroupMemberAudit createExpMemberAudit(
+      AccountGroup.Id groupId, Account.Id id, Account.Id addedBy, Timestamp addedOn) {
+    return new AccountGroupMemberAudit(
+        new AccountGroupMemberAudit.Key(id, groupId, addedOn), addedBy);
+  }
+
+  private AccountGroupByIdAud createExpGroupAudit(
+      AccountGroup.Id groupId, AccountGroup.UUID uuid, Account.Id addedBy, Timestamp addedOn) {
+    return new AccountGroupByIdAud(new AccountGroupByIdAud.Key(groupId, uuid, addedOn), addedBy);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
new file mode 100644
index 0000000..65e09f6
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -0,0 +1,19 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "db_tests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/group/db/testing",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
new file mode 100644
index 0000000..53bf866
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/GroupNameNotesTest.java
@@ -0,0 +1,213 @@
+// 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.group.db;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.util.Arrays;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+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.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GroupNameNotesTest extends GerritBaseTests {
+  private static final String SERVER_NAME = "Gerrit Server";
+  private static final String SERVER_EMAIL = "noreply@gerritcodereview.com";
+  private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
+
+  private AtomicInteger idCounter;
+  private Repository repo;
+
+  @Before
+  public void setUp() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    idCounter = new AtomicInteger();
+    repo = new InMemoryRepository(new DfsRepositoryDescription(AllUsersNameProvider.DEFAULT));
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void updateGroupNames() throws Exception {
+    GroupReference g1 = newGroup("a");
+    GroupReference g2 = newGroup("b");
+
+    PersonIdent ident = newPersonIdent();
+    updateGroupNames(ident, g1, g2);
+
+    ImmutableList<CommitInfo> log = log();
+    assertThat(log).hasSize(1);
+    assertThat(log.get(0)).parents().isEmpty();
+    assertThat(log.get(0)).message().isEqualTo("Store 2 group names");
+    assertThat(log.get(0)).author().matches(ident);
+    assertThat(log.get(0)).committer().matches(ident);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+
+    // Updating the same set of names is a no-op.
+    String commit = log.get(0).commit;
+    updateGroupNames(newPersonIdent(), g1, g2);
+    log = log();
+    assertThat(log).hasSize(1);
+    assertThat(log.get(0)).commit().isEqualTo(commit);
+  }
+
+  @Test
+  public void updateGroupNamesOverwritesExistingNotes() throws Exception {
+    GroupReference g1 = newGroup("a");
+    GroupReference g2 = newGroup("b");
+
+    TestRepository<?> tr = new TestRepository<>(repo);
+    ObjectId k1 = getNoteKey(g1);
+    ObjectId k2 = getNoteKey(g2);
+    ObjectId k3 = GroupNameNotes.getNoteKey(new AccountGroup.NameKey("c"));
+    PersonIdent ident = newPersonIdent();
+    ObjectId origCommitId =
+        tr.branch(REFS_GROUPNAMES)
+            .commit()
+            .message("Prepopulate group name")
+            .author(ident)
+            .committer(ident)
+            .add(k1.name(), "[group]\n\tuuid = a-1\n\tname = a\nanotherKey = foo\n")
+            .add(k2.name(), "[group]\n\tuuid = a-1\n\tname = b\n")
+            .add(k3.name(), "[group]\n\tuuid = c-3\n\tname = c\n")
+            .create()
+            .copy();
+
+    ident = newPersonIdent();
+    updateGroupNames(ident, g1, g2);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+
+    ImmutableList<CommitInfo> log = log();
+    assertThat(log).hasSize(2);
+    assertThat(log.get(0)).commit().isEqualTo(origCommitId.name());
+
+    assertThat(log.get(1)).message().isEqualTo("Store 2 group names");
+    assertThat(log.get(1)).author().matches(ident);
+    assertThat(log.get(1)).committer().matches(ident);
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      ObjectReader reader = rw.getObjectReader();
+      NoteMap noteMap =
+          NoteMap.read(reader, rw.parseCommit(ObjectId.fromString(log.get(1).commit)));
+      String note = new String(reader.open(noteMap.get(k1), OBJ_BLOB).getCachedBytes(), UTF_8);
+      // Old note content was overwritten.
+      assertThat(note).isEqualTo("[group]\n\tuuid = a-1\n\tname = a\n");
+    }
+  }
+
+  @Test
+  public void updateGroupNamesWithEmptyCollectionClearsAllNotes() throws Exception {
+    GroupReference g1 = newGroup("a");
+    GroupReference g2 = newGroup("b");
+
+    PersonIdent ident = newPersonIdent();
+    updateGroupNames(ident, g1, g2);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+
+    updateGroupNames(ident);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).isEmpty();
+
+    ImmutableList<CommitInfo> log = log();
+    assertThat(log).hasSize(2);
+    assertThat(log.get(1)).message().isEqualTo("Store 0 group names");
+  }
+
+  @Test
+  public void updateGroupNamesRejectsNonOneToOneGroupReferences() throws Exception {
+    assertIllegalArgument(
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name2"));
+    assertIllegalArgument(
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
+        new GroupReference(new AccountGroup.UUID("uuid2"), "name1"));
+    assertIllegalArgument(
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"),
+        new GroupReference(new AccountGroup.UUID("uuid1"), "name1"));
+  }
+
+  private GroupReference newGroup(String name) {
+    int id = idCounter.incrementAndGet();
+    return new GroupReference(new AccountGroup.UUID(name + "-" + id), name);
+  }
+
+  private static PersonIdent newPersonIdent() {
+    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
+  }
+
+  private static ObjectId getNoteKey(GroupReference g) {
+    return GroupNameNotes.getNoteKey(new AccountGroup.NameKey(g.getName()));
+  }
+
+  private void updateGroupNames(PersonIdent ident, GroupReference... groupRefs) throws Exception {
+    try (ObjectInserter inserter = repo.newObjectInserter()) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      GroupNameNotes.updateGroupNames(repo, inserter, bru, Arrays.asList(groupRefs), ident);
+      inserter.flush();
+      RefUpdateUtil.executeChecked(bru, repo);
+    }
+  }
+
+  private void assertIllegalArgument(GroupReference... groupRefs) throws Exception {
+    try (ObjectInserter inserter = repo.newObjectInserter()) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      PersonIdent ident = newPersonIdent();
+      try {
+        GroupNameNotes.updateGroupNames(repo, inserter, bru, Arrays.asList(groupRefs), ident);
+        assert_().fail("Expected IllegalArgumentException");
+      } catch (IllegalArgumentException e) {
+        assertThat(e).hasMessageThat().isEqualTo(GroupNameNotes.UNIQUE_REF_ERROR);
+      }
+    }
+  }
+
+  private ImmutableList<CommitInfo> log() throws Exception {
+    return GroupTestUtil.log(repo, REFS_GROUPNAMES);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java b/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
new file mode 100644
index 0000000..b142887
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
@@ -0,0 +1,476 @@
+// 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.group.db;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_GROUPNAMES;
+import static com.google.gerrit.server.group.db.GroupBundle.builder;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupByIdAud;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.testing.TestTimeUtil;
+import java.sql.Timestamp;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class GroupRebuilderTest extends AbstractGroupTest {
+  private AtomicInteger idCounter;
+  private Repository repo;
+  private GroupRebuilder rebuilder;
+  private GroupBundle.Factory bundleFactory;
+
+  @Before
+  public void setUp() throws Exception {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+    idCounter = new AtomicInteger();
+    repo = repoManager.createRepository(allUsersName);
+    rebuilder =
+        new GroupRebuilder(
+            GroupRebuilderTest::newPersonIdent,
+            allUsersName,
+            (project, repo, batch) ->
+                new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo, batch),
+            // Note that the expected name/email values in tests are not necessarily realistic,
+            // since they use these trivial name/email functions. GroupRebuilderIT checks the actual
+            // values.
+            AbstractGroupTest::newPersonIdent,
+            AbstractGroupTest::getAccountNameEmail,
+            AbstractGroupTest::getGroupName);
+    bundleFactory = new GroupBundle.Factory(new AuditLogReader(SERVER_ID));
+  }
+
+  @After
+  public void tearDown() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void minimalGroupFields() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(1);
+    assertCommit(log.get(0), "Create group", SERVER_NAME, SERVER_EMAIL);
+    assertThat(logGroupNames()).isEmpty();
+  }
+
+  @Test
+  public void allGroupFields() throws Exception {
+    AccountGroup g = newGroup("a");
+    g.setDescription("Description");
+    g.setOwnerGroupUUID(new AccountGroup.UUID("owner"));
+    g.setVisibleToAll(true);
+    GroupBundle b = builder().group(g).build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(1);
+    assertServerCommit(log.get(0), "Create group");
+  }
+
+  @Test
+  public void membersAndSubgroups() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2))
+            .byId(byId(g, "x"), byId(g, "y"))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(2);
+    assertServerCommit(log.get(0), "Create group");
+    assertServerCommit(
+        log.get(1),
+        "Update group\n"
+            + "\n"
+            + "Add: Account 1 <1@server-id>\n"
+            + "Add: Account 2 <2@server-id>\n"
+            + "Add-group: Group <x>\n"
+            + "Add-group: Group <y>");
+  }
+
+  @Test
+  public void memberAudit() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp t1 = TimeUtil.nowTs();
+    Timestamp t2 = TimeUtil.nowTs();
+    Timestamp t3 = TimeUtil.nowTs();
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1))
+            .memberAudit(addMember(g, 1, 8, t2), addAndRemoveMember(g, 2, 8, t1, 9, t3))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(4);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nRemove: Account 2 <2@server-id>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void memberAuditLegacyRemoved() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 2))
+            .memberAudit(
+                addAndLegacyRemoveMember(g, 1, 8, TimeUtil.nowTs()),
+                addMember(g, 2, 8, TimeUtil.nowTs()))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(4);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nRemove: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 8", "8@server-id");
+  }
+
+  @Test
+  public void unauditedMembershipsAddedAtEnd() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2), member(g, 3))
+            .memberAudit(addMember(g, 1, 8, TimeUtil.nowTs()))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(3);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1), "Update group\n\nAdd: Account 1 <1@server-id>", "Account 8", "8@server-id");
+    assertServerCommit(
+        log.get(2), "Update group\n\nAdd: Account 2 <2@server-id>\nAdd: Account 3 <3@server-id>");
+  }
+
+  @Test
+  public void byIdAudit() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp t1 = TimeUtil.nowTs();
+    Timestamp t2 = TimeUtil.nowTs();
+    Timestamp t3 = TimeUtil.nowTs();
+    GroupBundle b =
+        builder()
+            .group(g)
+            .byId(byId(g, "x"))
+            .byIdAudit(addById(g, "x", 8, t2), addAndRemoveById(g, "y", 8, t1, 9, t3))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(4);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(log.get(1), "Update group\n\nAdd-group: Group <y>", "Account 8", "8@server-id");
+    assertCommit(log.get(2), "Update group\n\nAdd-group: Group <x>", "Account 8", "8@server-id");
+    assertCommit(log.get(3), "Update group\n\nRemove-group: Group <y>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void unauditedByIdAddedAtEnd() throws Exception {
+    AccountGroup g = newGroup("a");
+    GroupBundle b =
+        builder()
+            .group(g)
+            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
+            .byIdAudit(addById(g, "x", 8, TimeUtil.nowTs()))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(3);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(log.get(1), "Update group\n\nAdd-group: Group <x>", "Account 8", "8@server-id");
+    assertServerCommit(log.get(2), "Update group\n\nAdd-group: Group <y>\nAdd-group: Group <z>");
+  }
+
+  @Test
+  public void auditsAtSameTimestampBrokenDownByType() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp ts = TimeUtil.nowTs();
+    int user = 8;
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2))
+            .memberAudit(
+                addMember(g, 1, user, ts),
+                addMember(g, 2, user, ts),
+                addAndRemoveMember(g, 3, user, ts, user, ts))
+            .byId(byId(g, "x"), byId(g, "y"))
+            .byIdAudit(
+                addById(g, "x", user, ts),
+                addById(g, "y", user, ts),
+                addAndRemoveById(g, "z", user, ts, user, ts))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(5);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1),
+        "Update group\n"
+            + "\n"
+            + "Add: Account 1 <1@server-id>\n"
+            + "Add: Account 2 <2@server-id>\n"
+            + "Add: Account 3 <3@server-id>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(
+        log.get(2), "Update group\n\nRemove: Account 3 <3@server-id>", "Account 8", "8@server-id");
+    assertCommit(
+        log.get(3),
+        "Update group\n"
+            + "\n"
+            + "Add-group: Group <x>\n"
+            + "Add-group: Group <y>\n"
+            + "Add-group: Group <z>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(log.get(4), "Update group\n\nRemove-group: Group <z>", "Account 8", "8@server-id");
+  }
+
+  @Test
+  public void auditsAtSameTimestampBrokenDownByUserAndType() throws Exception {
+    AccountGroup g = newGroup("a");
+    Timestamp ts = TimeUtil.nowTs();
+    int user1 = 8;
+    int user2 = 9;
+
+    GroupBundle b =
+        builder()
+            .group(g)
+            .members(member(g, 1), member(g, 2), member(g, 3))
+            .memberAudit(
+                addMember(g, 1, user1, ts), addMember(g, 2, user2, ts), addMember(g, 3, user1, ts))
+            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
+            .byIdAudit(
+                addById(g, "x", user1, ts), addById(g, "y", user2, ts), addById(g, "z", user1, ts))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(5);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(
+        log.get(1),
+        "Update group\n" + "\n" + "Add: Account 1 <1@server-id>\n" + "Add: Account 3 <3@server-id>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(
+        log.get(2),
+        "Update group\n\nAdd-group: Group <x>\nAdd-group: Group <z>",
+        "Account 8",
+        "8@server-id");
+    assertCommit(
+        log.get(3), "Update group\n\nAdd: Account 2 <2@server-id>", "Account 9", "9@server-id");
+    assertCommit(log.get(4), "Update group\n\nAdd-group: Group <y>", "Account 9", "9@server-id");
+  }
+
+  @Test
+  public void fixupCommitPostDatesAllAuditEventsEvenIfAuditEventsAreInTheFuture() throws Exception {
+    AccountGroup g = newGroup("a");
+    IntStream.range(0, 20).forEach(i -> TimeUtil.nowTs());
+    Timestamp future = TimeUtil.nowTs();
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+
+    GroupBundle b =
+        builder()
+            .group(g)
+            .byId(byId(g, "x"), byId(g, "y"), byId(g, "z"))
+            .byIdAudit(addById(g, "x", 8, future))
+            .build();
+
+    rebuilder.rebuild(repo, b, null);
+
+    assertThat(reload(g)).isEqualTo(b);
+    ImmutableList<CommitInfo> log = log(g);
+    assertThat(log).hasSize(3);
+    assertServerCommit(log.get(0), "Create group");
+    assertCommit(log.get(1), "Update group\n\nAdd-group: Group <x>", "Account 8", "8@server-id");
+    assertServerCommit(log.get(2), "Update group\n\nAdd-group: Group <y>\nAdd-group: Group <z>");
+
+    assertThat(log.stream().map(c -> c.committer.date).collect(toImmutableList()))
+        .named("%s", log)
+        .isOrdered();
+    assertThat(TimeUtil.nowTs()).isLessThan(future);
+  }
+
+  @Test
+  public void combineWithBatchGroupNameNotes() throws Exception {
+    AccountGroup g1 = newGroup("a");
+    AccountGroup g2 = newGroup("b");
+
+    GroupBundle b1 = builder().group(g1).build();
+    GroupBundle b2 = builder().group(g2).build();
+
+    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+
+    rebuilder.rebuild(repo, b1, bru);
+    rebuilder.rebuild(repo, b2, bru);
+    try (ObjectInserter inserter = repo.newObjectInserter()) {
+      ImmutableList<GroupReference> refs =
+          ImmutableList.of(GroupReference.forGroup(g1), GroupReference.forGroup(g2));
+      GroupNameNotes.updateGroupNames(repo, inserter, bru, refs, newPersonIdent());
+      inserter.flush();
+    }
+
+    assertThat(log(g1)).isEmpty();
+    assertThat(log(g2)).isEmpty();
+    assertThat(logGroupNames()).isEmpty();
+
+    RefUpdateUtil.executeChecked(bru, repo);
+
+    assertThat(log(g1)).hasSize(1);
+    assertThat(log(g2)).hasSize(1);
+    assertThat(logGroupNames()).hasSize(1);
+    assertThat(reload(g1)).isEqualTo(b1);
+    assertThat(reload(g2)).isEqualTo(b2);
+
+    assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
+  }
+
+  private GroupBundle reload(AccountGroup g) throws Exception {
+    return bundleFactory.fromNoteDb(repo, g.getGroupUUID());
+  }
+
+  private AccountGroup newGroup(String name) {
+    int id = idCounter.incrementAndGet();
+    return new AccountGroup(
+        new AccountGroup.NameKey(name),
+        new AccountGroup.Id(id),
+        new AccountGroup.UUID(name + "-" + id),
+        TimeUtil.nowTs());
+  }
+
+  private AccountGroupMember member(AccountGroup g, int accountId) {
+    return new AccountGroupMember(new AccountGroupMember.Key(new Account.Id(accountId), g.getId()));
+  }
+
+  private AccountGroupMemberAudit addMember(
+      AccountGroup g, int accountId, int adder, Timestamp addedOn) {
+    return new AccountGroupMemberAudit(member(g, accountId), new Account.Id(adder), addedOn);
+  }
+
+  private AccountGroupMemberAudit addAndLegacyRemoveMember(
+      AccountGroup g, int accountId, int adder, Timestamp addedOn) {
+    AccountGroupMemberAudit a = addMember(g, accountId, adder, addedOn);
+    a.removedLegacy();
+    return a;
+  }
+
+  private AccountGroupMemberAudit addAndRemoveMember(
+      AccountGroup g,
+      int accountId,
+      int adder,
+      Timestamp addedOn,
+      int removedBy,
+      Timestamp removedOn) {
+    AccountGroupMemberAudit a = addMember(g, accountId, adder, addedOn);
+    a.removed(new Account.Id(removedBy), removedOn);
+    return a;
+  }
+
+  private AccountGroupByIdAud addById(
+      AccountGroup g, String subgroupUuid, int adder, Timestamp addedOn) {
+    return new AccountGroupByIdAud(byId(g, subgroupUuid), new Account.Id(adder), addedOn);
+  }
+
+  private AccountGroupByIdAud addAndRemoveById(
+      AccountGroup g,
+      String subgroupUuid,
+      int adder,
+      Timestamp addedOn,
+      int removedBy,
+      Timestamp removedOn) {
+    AccountGroupByIdAud a = addById(g, subgroupUuid, adder, addedOn);
+    a.removed(new Account.Id(removedBy), removedOn);
+    return a;
+  }
+
+  private AccountGroupById byId(AccountGroup g, String subgroupUuid) {
+    return new AccountGroupById(
+        new AccountGroupById.Key(g.getId(), new AccountGroup.UUID(subgroupUuid)));
+  }
+
+  private ImmutableList<CommitInfo> log(AccountGroup g) throws Exception {
+    return GroupTestUtil.log(repo, RefNames.refsGroups(g.getGroupUUID()));
+  }
+
+  private ImmutableList<CommitInfo> logGroupNames() throws Exception {
+    return GroupTestUtil.log(repo, REFS_GROUPNAMES);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 74e1c09..d4ecb6d 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -29,10 +30,10 @@
 
 @Ignore
 public class FakeChangeIndex implements ChangeIndex {
-  static Schema<ChangeData> V1 =
+  static final Schema<ChangeData> V1 =
       new Schema<>(1, ImmutableList.<FieldDef<ChangeData, ?>>of(ChangeField.STATUS));
 
-  static Schema<ChangeData> V2 =
+  static final Schema<ChangeData> V2 =
       new Schema<>(2, ImmutableList.of(ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED));
 
   private static class Source implements ChangeDataSource {
@@ -58,6 +59,11 @@
     }
 
     @Override
+    public ResultSet<FieldBundle> readRaw() throws OrmException {
+      throw new UnsupportedOperationException("not implemented");
+    }
+
+    @Override
     public String toString() {
       return p.toString();
     }
diff --git a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index b9f6411..acb33e9 100644
--- a/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/javatests/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -77,7 +77,7 @@
     assertInvalidState("project1:refs/heads/foo:");
 
     assertThat(
-            StalenessChecker.parseStates(
+            RefState.parseStates(
                 byteArrays(
                     P1 + ":refs/heads/foo:" + SHA1,
                     P1 + ":refs/heads/bar:" + SHA2,
@@ -91,7 +91,7 @@
 
   private static void assertInvalidState(String state) {
     try {
-      StalenessChecker.parseStates(byteArrays(state));
+      RefState.parseStates(byteArrays(state));
       assert_().fail("expected IllegalArgumentException");
     } catch (IllegalArgumentException e) {
       // Expected.
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java b/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java
index 650262a..722dd08 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.common.TimeUtil.roundToSecond;
+import static com.google.gerrit.common.TimeUtil.truncateToSecond;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.NOTE_DB;
 import static com.google.gerrit.server.notedb.ChangeBundle.Source.REVIEW_DB;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
@@ -1098,7 +1098,7 @@
     PatchSet ps1 = new PatchSet(c.currentPatchSetId());
     ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
     ps1.setUploader(accountId);
-    ps1.setCreatedOn(roundToSecond(TimeUtil.nowTs()));
+    ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs()));
     PatchSet ps2 = clone(ps1);
     ps2.setCreatedOn(TimeUtil.nowTs());
 
@@ -1152,7 +1152,7 @@
     PatchSet ps1 = new PatchSet(c.currentPatchSetId());
     ps1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
     ps1.setUploader(accountId);
-    ps1.setCreatedOn(roundToSecond(TimeUtil.nowTs()));
+    ps1.setCreatedOn(truncateToSecond(TimeUtil.nowTs()));
     ps1.setPushCertificate("some cert");
     PatchSet ps2 = clone(ps1);
     ps2.setPushCertificate(ps2.getPushCertificate() + "\n\n");
@@ -1579,7 +1579,7 @@
         new PatchSetApproval(
             new PatchSetApproval.Key(c.currentPatchSetId(), accountId, new LabelId("Code-Review")),
             (short) 1,
-            roundToSecond(TimeUtil.nowTs()));
+            truncateToSecond(TimeUtil.nowTs()));
     PatchSetApproval a2 = clone(a1);
     a2.setGranted(TimeUtil.nowTs());
 
@@ -1827,7 +1827,7 @@
             5,
             accountId,
             null,
-            roundToSecond(TimeUtil.nowTs()));
+            truncateToSecond(TimeUtil.nowTs()));
     PatchLineComment c2 = clone(c1);
     c2.setWrittenOn(TimeUtil.nowTs());
 
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 6b4c5ea..61e62f7 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -19,13 +19,18 @@
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
@@ -41,11 +46,15 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.index.account.AccountField;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -62,6 +71,7 @@
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
@@ -111,6 +121,10 @@
 
   @Inject protected GitRepositoryManager repoManager;
 
+  @Inject protected AccountIndexCollection indexes;
+
+  @Inject protected ExternalIds externalIds;
+
   protected LifecycleManager lifecycle;
   protected Injector injector;
   protected ReviewDb db;
@@ -424,6 +438,38 @@
     assertQuery("name:" + quote(newName), user1);
   }
 
+  @Test
+  public void rawDocument() throws Exception {
+    AccountInfo userInfo = gApi.accounts().id(user.getAccountId().get()).get();
+
+    Optional<FieldBundle> rawFields =
+        indexes
+            .getSearchIndex()
+            .getRaw(
+                new Account.Id(userInfo._accountId),
+                QueryOptions.create(
+                    IndexConfig.createDefault(),
+                    0,
+                    1,
+                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+
+    assertThat(rawFields.isPresent()).isTrue();
+    assertThat(rawFields.get().getValue(AccountField.ID)).isEqualTo(userInfo._accountId);
+
+    List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
+    List<ByteArrayWrapper> blobs = new ArrayList<>();
+    for (AccountExternalIdInfo info : externalIdInfos) {
+      blobs.add(
+          new ByteArrayWrapper(externalIds.get(ExternalId.Key.parse(info.identity)).toByteArray()));
+    }
+    assertThat(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE)).hasSize(blobs.size());
+    assertThat(
+            Streams.stream(rawFields.get().getValue(AccountField.EXTERNAL_ID_STATE))
+                .map(b -> new ByteArrayWrapper(b))
+                .collect(toList()))
+        .containsExactlyElementsIn(blobs);
+  }
+
   protected AccountInfo newAccount(String username) throws Exception {
     return newAccountWithEmail(username, null);
   }
@@ -590,4 +636,26 @@
   protected static Iterable<Integer> ids(List<AccountInfo> accounts) {
     return accounts.stream().map(a -> a._accountId).collect(toList());
   }
+
+  /** Boiler plate code to check two byte arrays for equality */
+  private static class ByteArrayWrapper {
+    private byte[] arr;
+
+    private ByteArrayWrapper(byte[] arr) {
+      this.arr = arr;
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (!(other instanceof ByteArrayWrapper)) {
+        return false;
+      }
+      return Arrays.equals(arr, ((ByteArrayWrapper) other).arr);
+    }
+
+    @Override
+    public int hashCode() {
+      return Arrays.hashCode(arr);
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
new file mode 100644
index 0000000..e2a4d1f
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -0,0 +1,38 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryAccountsTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
new file mode 100644
index 0000000..a1af6d0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -0,0 +1,48 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryChangesTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    runtime_deps = ["//prolog:gerrit-prolog-common"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gwtorm",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:gwtorm",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index cc7b863..54c22f9 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -27,7 +27,10 @@
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
 import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -64,6 +67,7 @@
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
 import org.junit.Before;
@@ -384,6 +388,26 @@
     assertQuery("description:" + newDescription, group1);
   }
 
+  @Test
+  public void rawDocument() throws Exception {
+    GroupInfo group1 = createGroup(name("group1"));
+    AccountGroup.UUID uuid = new AccountGroup.UUID(group1.id);
+
+    Optional<FieldBundle> rawFields =
+        indexes
+            .getSearchIndex()
+            .getRaw(
+                uuid,
+                QueryOptions.create(
+                    IndexConfig.createDefault(),
+                    0,
+                    10,
+                    indexes.getSearchIndex().getSchema().getStoredFields().keySet()));
+
+    assertThat(rawFields.isPresent()).isTrue();
+    assertThat(rawFields.get().getValue(GroupField.UUID)).isEqualTo(uuid.get());
+  }
+
   private Account.Id createAccountOutsideRequestContext(
       String username, String fullName, String email, boolean active) throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
new file mode 100644
index 0000000..49bc938
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -0,0 +1,38 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryGroupsTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
new file mode 100644
index 0000000..d350faf
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -0,0 +1,37 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+ABSTRACT_QUERY_TEST = ["AbstractQueryProjectsTest.java"]
+
+java_library(
+    name = "abstract_query_tests",
+    testonly = 1,
+    srcs = ABSTRACT_QUERY_TEST,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+junit_tests(
+    name = "lucene_query_test",
+    size = "large",
+    srcs = glob(
+        ["*.java"],
+        exclude = ABSTRACT_QUERY_TEST,
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/rules/BUILD b/javatests/com/google/gerrit/server/rules/BUILD
new file mode 100644
index 0000000..04a6485
--- /dev/null
+++ b/javatests/com/google/gerrit/server/rules/BUILD
@@ -0,0 +1,19 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "prolog_tests",
+    srcs = glob(["*.java"]),
+    resource_strip_prefix = "prologtests",
+    resources = ["//prologtests:gerrit_common_test"],
+    deps = [
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/project/testing:project-test-util",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:truth",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/prolog:runtime",
+        "//prolog:gerrit-prolog-common",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/rules/PrologTestCase.java b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
index 5fc6ce7..e8eea2d 100644
--- a/javatests/com/google/gerrit/server/rules/PrologTestCase.java
+++ b/javatests/com/google/gerrit/server/rules/PrologTestCase.java
@@ -41,8 +41,10 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import org.junit.Ignore;
 
 /** Base class for any tests written in Prolog. */
+@Ignore
 public abstract class PrologTestCase extends GerritBaseTests {
   private static final SymbolTerm test_1 = SymbolTerm.intern("test", 1);
 
diff --git a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index bdcee40..e4b0da5 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.GroupsMigration;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.testing.InMemoryDatabase;
 import com.google.gerrit.testing.InMemoryH2Type;
@@ -115,6 +116,7 @@
 
                     bind(SystemGroupBackend.class);
                     install(new NotesMigration.Module());
+                    install(new GroupsMigration.Module());
                     bind(MetricMaker.class).to(DisabledMetricMaker.class);
                   }
                 })
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index 51d3e47..9745f9e 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -212,11 +212,11 @@
      * Check whether there is no newer patch than the latest patch that was
      * available when this change was loaded.
      *
-     * @return {Promise<boolean>} A promise that yields true if the latest patch
+     * @return {Promise<!Object>} A promise that yields true if the latest patch
      *     has been loaded, and false if a newer patch has been uploaded in the
      *     meantime. The promise is rejected on network error.
      */
-    fetchIsLatestKnown(change, restAPI) {
+    fetchChangeUpdates(change, restAPI) {
       const knownLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
           Gerrit.PatchSetBehavior.computeAllPatchSets(change));
       return restAPI.getChangeDetail(change._number)
@@ -226,7 +226,11 @@
             }
             const actualLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
                 Gerrit.PatchSetBehavior.computeAllPatchSets(detail));
-            return actualLatest <= knownLatest;
+            return {
+              isLatest: actualLatest <= knownLatest,
+              newStatus: change.status !== detail.status ? detail.status : null,
+              newMessages: change.messages.length < detail.messages.length,
+            };
           });
     },
 
@@ -241,6 +245,16 @@
       const findNum = rev => rev._number + '' === patchNum + '';
       return revisions.findIndex(findNum);
     },
+
+    /**
+     * Convert parent indexes from patch range expressions to numbers.
+     * For example, in a patch range expression `"-3"` becomes `3`.
+     * @param {number|string} rangeBase
+     * @return {number}
+     */
+    getParentIndex(rangeBase) {
+      return -parseInt(rangeBase + '', 10);
+    },
   };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index afdb4ac..7116c5d 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -35,31 +35,37 @@
       assert.equal(get(revisions, '3'), undefined);
     });
 
-    test('fetchIsLatestKnown on latest', done => {
+    test('fetchChangeUpdates on latest', done => {
       const knownChange = {
         revisions: {
           sha1: {description: 'patch 1', _number: 1},
           sha2: {description: 'patch 2', _number: 2},
         },
+        status: 'NEW',
+        messages: [],
       };
       const mockRestApi = {
         getChangeDetail() {
           return Promise.resolve(knownChange);
         },
       };
-      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
-          .then(isLatest => {
-            assert.isTrue(isLatest);
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isTrue(result.isLatest);
+            assert.isNotOk(result.newStatus);
+            assert.isFalse(result.newMessages);
             done();
           });
     });
 
-    test('fetchIsLatestKnown not on latest', done => {
+    test('fetchChangeUpdates not on latest', done => {
       const knownChange = {
         revisions: {
           sha1: {description: 'patch 1', _number: 1},
           sha2: {description: 'patch 2', _number: 2},
         },
+        status: 'NEW',
+        messages: [],
       };
       const actualChange = {
         revisions: {
@@ -67,15 +73,81 @@
           sha2: {description: 'patch 2', _number: 2},
           sha3: {description: 'patch 3', _number: 3},
         },
+        status: 'NEW',
+        messages: [],
       };
       const mockRestApi = {
         getChangeDetail() {
           return Promise.resolve(actualChange);
         },
       };
-      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
-          .then(isLatest => {
-            assert.isFalse(isLatest);
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isFalse(result.isLatest);
+            assert.isNotOk(result.newStatus);
+            assert.isFalse(result.newMessages);
+            done();
+          });
+    });
+
+    test('fetchChangeUpdates new status', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'NEW',
+        messages: [],
+      };
+      const actualChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'MERGED',
+        messages: [],
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(actualChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isTrue(result.isLatest);
+            assert.equal(result.newStatus, 'MERGED');
+            assert.isFalse(result.newMessages);
+            done();
+          });
+    });
+
+    test('fetchChangeUpdates new messages', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'NEW',
+        messages: [],
+      };
+      const actualChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+        status: 'NEW',
+        messages: [{message: 'blah blah'}],
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(actualChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchChangeUpdates(knownChange, mockRestApi)
+          .then(result => {
+            assert.isTrue(result.isLatest);
+            assert.isNotOk(result.newStatus);
+            assert.isTrue(result.newMessages);
             done();
           });
     });
@@ -245,5 +317,10 @@
       sorted.splice(2, 0, edit);
       assert.deepEqual(sort(revisions), sorted);
     });
+
+    test('getParentIndex', () => {
+      assert.equal(Gerrit.PatchSetBehavior.getParentIndex('-13'), 13);
+      assert.equal(Gerrit.PatchSetBehavior.getParentIndex(-4), 4);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
index 9fe10bc..2d84179 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -70,10 +70,10 @@
           confirm-label="Create"
           on-confirm="_handleCreateGroup"
           on-cancel="_handleCloseCreate">
-        <div class="header">
+        <div class="header" slot="header">
           Create Group
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           <gr-create-group-dialog
               has-new-group-name="{{_hasNewGroupName}}"
               params="[[params]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
index e132100..da20181 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
@@ -30,8 +30,8 @@
         confirm-label="Delete [[_computeItemName(itemType)]]"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">[[_computeItemName(itemType)]] Deletion</div>
-      <div class="main">
+      <div class="header" slot="header">[[_computeItemName(itemType)]] Deletion</div>
+      <div class="main" slot="main">
         <label for="branchInput">
           Do you really want to delete the following [[_computeItemName(itemType)]]?
         </label>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
index f43403a..4274749 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
@@ -20,6 +20,7 @@
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -76,10 +77,10 @@
           disabled="[[!_canCreate]]"
           on-confirm="_handleCreateChange"
           on-cancel="_handleCloseCreateChange">
-        <div class="header">
+        <div class="header" slot="header">
           Create Change
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           <gr-create-change-dialog
               id="createNewChangeModal"
               can-create="{{_canCreate}}"
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html
index 85e8f83..0ed278a 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html
@@ -187,10 +187,10 @@
           confirm-label="Create"
           on-confirm="_handleCreateItem"
           on-cancel="_handleCloseCreate">
-        <div class="header">
+        <div class="header" slot="header">
           Create [[_computeItemName(detailType)]]
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           <gr-create-pointer-dialog
               id="createNewModal"
               detail-type="[[_computeItemName(detailType)]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
index 6c45704..958d2dc 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
@@ -81,10 +81,10 @@
           confirm-label="Create"
           on-confirm="_handleCreateProject"
           on-cancel="_handleCloseCreate">
-        <div class="header">
+        <div class="header" slot="header">
           Create Project
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           <gr-create-project-dialog
               has-new-project-name="{{_hasNewProjectName}}"
               params="[[params]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project.js b/polygerrit-ui/app/elements/admin/gr-project/gr-project.js
index 54b02aa..06d553a 100644
--- a/polygerrit-ui/app/elements/admin/gr-project/gr-project.js
+++ b/polygerrit-ui/app/elements/admin/gr-project/gr-project.js
@@ -165,7 +165,7 @@
     _formatBooleanSelect(item) {
       if (!item) { return; }
       let inheritLabel = 'Inherit';
-      if (item.inherited_value) {
+      if (!(item.inherited_value === undefined)) {
         inheritLabel = `Inherit (${item.inherited_value})`;
       }
       return [
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html b/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html
index a86bc03..4f9b098 100644
--- a/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html
@@ -174,7 +174,7 @@
     });
 
     test('_formatBooleanSelect', () => {
-      let item = {inherited_value: 'true'};
+      let item = {inherited_value: true};
       assert.deepEqual(element._formatBooleanSelect(item), [
         {
           label: 'Inherit (true)',
@@ -189,6 +189,21 @@
         },
       ]);
 
+      item = {inherited_value: false};
+      assert.deepEqual(element._formatBooleanSelect(item), [
+        {
+          label: 'Inherit (false)',
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ]);
+
       // For items without inherited values
       item = {};
       assert.deepEqual(element._formatBooleanSelect(item), [
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 79a9245..481cd76 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -52,11 +52,6 @@
         /* px because don't have the same font size */
         margin-left: 12px;
       }
-      gr-button[data-action-key='submit'] {
-        --gr-button-background: #f67070;
-        --gr-button-color: #fff;
-        --gr-button-hover-background-color: #dc5151;
-      }
       #actionLoadingMessage {
         align-items: center;
         color: #777;
@@ -107,11 +102,6 @@
                 on-tap="_handleActionTap">[[action.label]]</gr-button>
           </template>
         </section>
-        <gr-button
-            class="reply"
-            secondary
-            disabled="[[replyDisabled]]"
-            on-tap="_handleReplyTap">[[replyButtonLabel]]</gr-button>
         <section id="secondaryActions"
             hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]">
           <template
@@ -179,10 +169,10 @@
           confirm-label="Delete"
           on-cancel="_handleConfirmDialogCancel"
           on-confirm="_handleDeleteConfirm">
-        <div class="header">
+        <div class="header" slot="header">
           Delete Change
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           Do you really want to delete the change?
         </div>
       </gr-confirm-dialog>
@@ -192,10 +182,10 @@
           confirm-label="Delete"
           on-cancel="_handleConfirmDialogCancel"
           on-confirm="_handleDeleteEditConfirm">
-        <div class="header">
+        <div class="header" slot="header">
           Delete Change Edit
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           Do you really want to delete the edit?
         </div>
       </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index c86c251..b2a6f0d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -160,12 +160,6 @@
      * @event reload-change
      */
 
-     /**
-     * Fired when the reply button is tapped.
-     *
-     * @event reply-tap
-     */
-
     /**
      * Fired when an action is tapped.
      *
@@ -214,8 +208,6 @@
         type: Object,
         value() { return {}; },
       },
-      replyButtonLabel: String,
-      replyDisabled: Boolean,
 
       _loading: {
         type: Boolean,
@@ -729,11 +721,6 @@
       this._showActionDialog(this.$.confirmRevertDialog);
     },
 
-    _handleReplyTap(e) {
-      e.preventDefault();
-      this.dispatchEvent(new CustomEvent('reply-tap'));
-    },
-
     _handleActionTap(e) {
       e.preventDefault();
       const el = Polymer.dom(e).localTarget;
@@ -1062,9 +1049,9 @@
         this._handleResponseError(response);
       };
 
-      return this.fetchIsLatestKnown(this.change, this.$.restAPI)
-          .then(isLatest => {
-            if (!isLatest) {
+      return this.fetchChangeUpdates(this.change, this.$.restAPI)
+          .then(result => {
+            if (!result.isLatest) {
               this.fire('show-alert', {
                 message: 'Cannot set label: a newer patch has been ' +
                     'uploaded to this change.',
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index a3932c2..62b4626 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -194,7 +194,7 @@
         const buttonEls = Polymer.dom(element.root)
             .querySelectorAll('gr-button');
         const menuItems = element.$.moreActions.items;
-        assert.equal(buttonEls.length + menuItems.length, 7);
+        assert.equal(buttonEls.length + menuItems.length, 6);
         assert.isFalse(element.hidden);
         done();
       });
@@ -241,8 +241,8 @@
     test('submit change', done => {
       sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
           .returns(Promise.resolve('test'));
-      sandbox.stub(element, 'fetchIsLatestKnown',
-          () => { return Promise.resolve(true); });
+      sandbox.stub(element, 'fetchChangeUpdates',
+          () => { return Promise.resolve({isLatest: true}); });
       element.change = {
         revisions: {
           rev1: {_number: 1},
@@ -1264,8 +1264,8 @@
         let sendStub;
 
         setup(() => {
-          sandbox.stub(element, 'fetchIsLatestKnown')
-              .returns(Promise.resolve(true));
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: true}));
           sendStub = sandbox.stub(element.$.restAPI, 'getChangeURLAndSend')
               .returns(Promise.resolve({}));
         });
@@ -1293,8 +1293,8 @@
 
       suite('failure modes', () => {
         test('non-latest', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown')
-              .returns(Promise.resolve(false));
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: false}));
           const sendStub = sandbox.stub(element.$.restAPI,
               'getChangeURLAndSend');
 
@@ -1307,8 +1307,8 @@
         });
 
         test('send fails', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown')
-              .returns(Promise.resolve(true));
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({isLatest: true}));
           const sendStub = sandbox.stub(element.$.restAPI,
               'getChangeURLAndSend',
               (num, method, patchNum, endpoint, payload, onErr) => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index a999641..caf2fa3 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -14,11 +14,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
-<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
+<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
@@ -327,6 +329,10 @@
           </template>
         </span>
       </section>
+      <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
+        <gr-endpoint-param name="revision" value="[[currentRevision]]"></gr-endpoint-param>
+      </gr-endpoint-decorator>
     </gr-external-style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index db6c44c..00b4c2a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -37,6 +37,8 @@
     properties: {
       /** @type {?} */
       change: Object,
+      /** @type {?} */
+      currentRevision: Object,
       commitInfo: Object,
       mutable: Boolean,
       /**
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 522d34e..0b88f22 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -38,6 +38,9 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      stub('gr-endpoint-decorator', {
+        _import: sandbox.stub().returns(Promise.resolve()),
+      });
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getLoggedIn() { return Promise.resolve(false); },
@@ -615,5 +618,29 @@
         });
       });
     });
+
+    suite('plugin endpoints', () => {
+      test('endpoint params', done => {
+        element.change = {labels: {}};
+        element.currentRevision = {};
+        let hookEl;
+        let plugin;
+        Gerrit.install(
+            p => {
+              plugin = p;
+              plugin.hook('change-metadata-item').getLastAttached().then(
+                  el => hookEl = el);
+            },
+            '0.1',
+            'http://some/plugins/url.html');
+        Gerrit._setPluginsCount(0);
+        flush(() => {
+          assert.strictEqual(hookEl.plugin, plugin);
+          assert.strictEqual(hookEl.change, element.change);
+          assert.strictEqual(hookEl.revision, element.currentRevision);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index fa53bfe..56548de 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -15,7 +15,6 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
@@ -25,6 +24,7 @@
 <link rel="import" href="../../diff/gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../../edit/gr-edit-constants.html">
 <link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../plugins/gr-endpoint-param/gr-endpoint-param.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
@@ -87,6 +87,9 @@
       .header-title .headerSubject {
         font-family: var(--font-family-bold);
       }
+      .replyContainer {
+        margin-bottom: 1em;
+      }
       gr-change-star {
         margin-right: .25em;
         vertical-align: -.425em;
@@ -133,6 +136,10 @@
       }
       .editCommitMessage {
         margin-top: 1em;
+        --gr-button: {
+          padding-left: 0;
+          padding-right: 0;
+        }
       }
       .changeStatuses,
       .commitActions {
@@ -343,14 +350,14 @@
               edit-loaded="[[_editLoaded]]"
               edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
               on-reload-change="_handleReloadChange"
-              on-download-tap="_handleOpenDownloadDialog"
-              on-reply-tap="_handleReplyTap"></gr-change-actions>
+              on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
         </div><!-- end commit actions -->
       </div><!-- end header -->
       <section class="changeInfo">
         <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
           <gr-change-metadata
               change="{{_change}}"
+              revision="[[_currentRevision]]"
               commit-info="[[_commitInfo]]"
               server-config="[[_serverConfig]]"
               mutable="[[_loggedIn]]"
@@ -365,6 +372,14 @@
           <hr class="mobile">
           <div id="commitAndRelated" class="hideOnMobileOverlay">
             <div class="commitContainer">
+              <div class="replyContainer">
+                  <gr-button
+                      id="replyBtn"
+                      class="reply"
+                      secondary
+                      disabled="[[_replyDisabled]]"
+                      on-tap="_handleReplyTap">[[_replyButtonLabel]]</gr-button>
+              </div>
               <div
                   id="commitMessage"
                   class$="commitMessage [[_computeCommitClass(_commitCollapsed, _latestCommitMessage)]]">
@@ -435,7 +450,7 @@
             all-patch-sets="[[_allPatchSets]]"
             change="[[_change]]"
             change-num="[[_changeNum]]"
-            comments="[[_changeComments.comments]]"
+            change-comments="[[_changeComments]]"
             commit-info="[[_commitInfo]]"
             change-url="[[_computeChangeUrl(_change)]]"
             edit-loaded="[[_editLoaded]]"
@@ -472,6 +487,10 @@
             on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
       </section>
       <gr-endpoint-decorator name="change-view-integration">
+        <gr-endpoint-param name="change" value="[[_change]]">
+        </gr-endpoint-param>
+        <gr-endpoint-param name="revision" value="[[_currentRevision]]">
+        </gr-endpoint-param>
       </gr-endpoint-decorator>
       <gr-messages-list id="messageList"
           class="hideOnMobileOverlay"
@@ -495,7 +514,6 @@
         class="scrollable"
         no-cancel-on-outside-click
         no-cancel-on-esc-key
-        on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
           change="{{_change}}"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index f00f386..3d7d79a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -41,6 +41,14 @@
 
   const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
+  const ReloadToastMessage = {
+    NEWER_REVISION: 'A newer patch set has been uploaded',
+    RESTORED: 'This change has been restored',
+    ABANDONED: 'This change has been abandoned',
+    MERGED: 'This change has been merged',
+    NEW_MESSAGE: 'There are new messages on this change',
+  };
+
   Polymer({
     is: 'gr-change-view',
 
@@ -158,6 +166,7 @@
         type: Boolean,
         value: true,
       },
+      _currentRevision: Object,
       _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
@@ -410,7 +419,7 @@
 
     _handleReplyTap(e) {
       e.preventDefault();
-      this._openReplyDialog();
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
     },
 
     _handleOpenDiffPrefs() {
@@ -438,15 +447,7 @@
         this.$.replyDialog.draft = quoteStr;
       }
       this.$.replyDialog.quote = quoteStr;
-      this._openReplyDialog();
-    },
-
-    _handleReplyOverlayOpen(e) {
-      // This is needed so that focus is not set on the reply overlay
-      // when the suggestion overaly from gr-autogrow-textarea opens.
-      if (e.target === this.$.replyOverlay) {
-        this.$.replyDialog.focus();
-      }
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
     },
 
     _handleHideBackgroundContent() {
@@ -628,7 +629,7 @@
         if (!loggedIn) { return; }
 
         if (this.viewState.showReplyDialog) {
-          this._openReplyDialog();
+          this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
           // TODO(kaspern@): Find a better signal for when to call center.
           this.async(() => { this.$.replyOverlay.center(); }, 100);
           this.async(() => { this.$.replyOverlay.center(); }, 1000);
@@ -775,7 +776,7 @@
         }
 
         e.preventDefault();
-        this._openReplyDialog();
+        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
       });
     },
 
@@ -983,6 +984,7 @@
                 parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
 
             this._change = change;
+            this._currentRevision = currentRevision;
             if (!this._patchRange || !this._patchRange.patchNum ||
                 this.patchNumEquals(this._patchRange.patchNum,
                     currentRevision._number)) {
@@ -1249,24 +1251,37 @@
       }
 
       this._updateCheckTimerHandle = this.async(() => {
-        this.fetchIsLatestKnown(this._change, this.$.restAPI)
-            .then(latest => {
-              if (latest) {
-                this._startUpdateCheckTimer();
-              } else {
-                this._cancelUpdateCheckTimer();
-                this.fire('show-alert', {
-                  message: 'A newer patch set has been uploaded.',
-                  // Persist this alert.
-                  dismissOnNavigation: true,
-                  action: 'Reload',
-                  callback: function() {
-                    // Load the current change without any patch range.
-                    Gerrit.Nav.navigateToChange(this._change);
-                  }.bind(this),
-                });
-              }
-            });
+        this.fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
+          let toastMessage = null;
+          if (!result.isLatest) {
+            toastMessage = ReloadToastMessage.NEWER_REVISION;
+          } else if (result.newStatus === this.ChangeStatus.MERGED) {
+            toastMessage = ReloadToastMessage.MERGED;
+          } else if (result.newStatus === this.ChangeStatus.ABANDONED) {
+            toastMessage = ReloadToastMessage.ABANDONED;
+          } else if (result.newStatus === this.ChangeStatus.NEW) {
+            toastMessage = ReloadToastMessage.RESTORED;
+          } else if (result.newMessages) {
+            toastMessage = ReloadToastMessage.NEW_MESSAGE;
+          }
+
+          if (!toastMessage) {
+            this._startUpdateCheckTimer();
+            return;
+          }
+
+          this._cancelUpdateCheckTimer();
+          this.fire('show-alert', {
+            message: toastMessage,
+            // Persist this alert.
+            dismissOnNavigation: true,
+            action: 'Reload',
+            callback: function() {
+              // Load the current change without any patch range.
+              Gerrit.Nav.navigateToChange(this._change);
+            }.bind(this),
+          });
+        });
       }, this._serverConfig.change.update_delay * 1000);
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index aeb98d5..d3e1d72 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -49,6 +49,11 @@
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      stub('gr-endpoint-decorator', {
+        _import: sandbox.stub().returns(Promise.resolve()),
+      });
+      // Since _endpoints are global, must reset state.
+      Gerrit._endpoints = new GrPluginEndpoints();
       navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({test: 'config'}); },
@@ -57,6 +62,7 @@
       });
       element = fixture('basic');
       sandbox.stub(element.$.actions, 'reload').returns(Promise.resolve());
+      Gerrit._setPluginsCount(0);
     });
 
     teardown(done => {
@@ -120,14 +126,20 @@
 
       test('A toggles overlay when logged in', done => {
         sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown')
-            .returns(Promise.resolve(true));
+        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates')
+            .returns(Promise.resolve({isLatest: true}));
         element._change = {labels: {}};
+        const openSpy = sandbox.spy(element, '_openReplyDialog');
+
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
         flush(() => {
           assert.isTrue(element.$.replyOverlay.opened);
           element.$.replyOverlay.close();
           assert.isFalse(element.$.replyOverlay.opened);
+          assert(openSpy.lastCall.calledWithExactly(
+              element.$.replyDialog.FocusTarget.ANY),
+              '_openReplyDialog should have been passed ANY');
+          assert.equal(openSpy.callCount, 1);
           done();
         });
       });
@@ -834,6 +846,27 @@
       });
     });
 
+    test('_openReplyDialog called with `ANY` when coming from tap event',
+        () => {
+          const openStub = sandbox.stub(element, '_openReplyDialog');
+          element._serverConfig = {};
+          MockInteractions.tap(element.$.replyBtn);
+          assert(openStub.lastCall.calledWithExactly(
+              element.$.replyDialog.FocusTarget.ANY),
+              '_openReplyDialog should have been passed ANY');
+          assert.equal(openStub.callCount, 1);
+        });
+
+    test('_openReplyDialog called with `BODY` when coming from message reply' +
+        'event', () => {
+      const openStub = sandbox.stub(element, '_openReplyDialog');
+      element.$.messageList.fire('reply', {message: {message: 'text'}});
+      assert(openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.BODY),
+          '_openReplyDialog should have been passed BODY');
+      assert.equal(openStub.callCount, 1);
+    });
+
     test('reply dialog focus can be controlled', () => {
       const FocusTarget = element.$.replyDialog.FocusTarget;
       const openStub = sandbox.stub(element, '_openReplyDialog');
@@ -842,11 +875,13 @@
       element._handleShowReplyDialog(e);
       assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
           '_openReplyDialog should have been passed REVIEWERS');
+      assert.equal(openStub.callCount, 1);
 
       e.detail.value = {ccsOnly: true};
       element._handleShowReplyDialog(e);
       assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
           '_openReplyDialog should have been passed CCS');
+      assert.equal(openStub.callCount, 2);
     });
 
     test('getUrlParameter functionality', () => {
@@ -940,8 +975,8 @@
     suite('reply dialog tests', () => {
       setup(() => {
         sandbox.stub(element.$.replyDialog, '_draftChanged');
-        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown',
-            () => { return Promise.resolve(true); });
+        sandbox.stub(element.$.replyDialog, 'fetchChangeUpdates',
+            () => { return Promise.resolve({isLatest: true}); });
         element._change = {labels: {}};
       });
 
@@ -991,8 +1026,8 @@
 
     suite('commit message expand/collapse', () => {
       setup(() => {
-        sandbox.stub(element, 'fetchIsLatestKnown',
-            () => { return Promise.resolve(false); });
+        sandbox.stub(element, 'fetchChangeUpdates',
+            () => { return Promise.resolve({isLatest: false}); });
       });
 
       test('commitCollapseToggle hidden for short commit message', () => {
@@ -1142,29 +1177,58 @@
         });
 
         test('_startUpdateCheckTimer negative delay', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown');
+          sandbox.stub(element, 'fetchChangeUpdates');
 
           element._serverConfig = {change: {update_delay: -1}};
 
           assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isFalse(element.fetchIsLatestKnown.called);
+          assert.isFalse(element.fetchChangeUpdates.called);
         });
 
         test('_startUpdateCheckTimer up-to-date', () => {
-          sandbox.stub(element, 'fetchIsLatestKnown',
-              () => { return Promise.resolve(true); });
+          sandbox.stub(element, 'fetchChangeUpdates',
+              () => { return Promise.resolve({isLatest: true}); });
 
           element._serverConfig = {change: {update_delay: 12345}};
 
           assert.isTrue(element._startUpdateCheckTimer.called);
-          assert.isTrue(element.fetchIsLatestKnown.called);
+          assert.isTrue(element.fetchChangeUpdates.called);
           assert.equal(element.async.lastCall.args[1], 12345 * 1000);
         });
 
         test('_startUpdateCheckTimer out-of-date shows an alert', done => {
-          sandbox.stub(element, 'fetchIsLatestKnown',
-              () => { return Promise.resolve(false); });
-          element.addEventListener('show-alert', () => {
+          sandbox.stub(element, 'fetchChangeUpdates',
+              () => { return Promise.resolve({isLatest: false}); });
+          element.addEventListener('show-alert', e => {
+            assert.equal(e.detail.message,
+                'A newer patch set has been uploaded');
+            done();
+          });
+          element._serverConfig = {change: {update_delay: 12345}};
+        });
+
+        test('_startUpdateCheckTimer new status shows an alert', done => {
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({
+                isLatest: true,
+                newStatus: element.ChangeStatus.MERGED,
+              }));
+          element.addEventListener('show-alert', e => {
+            assert.equal(e.detail.message, 'This change has been merged');
+            done();
+          });
+          element._serverConfig = {change: {update_delay: 12345}};
+        });
+
+        test('_startUpdateCheckTimer new messages shows an alert', done => {
+          sandbox.stub(element, 'fetchChangeUpdates')
+              .returns(Promise.resolve({
+                isLatest: true,
+                newMessages: true,
+              }));
+          element.addEventListener('show-alert', e => {
+            assert.equal(e.detail.message,
+                'There are new messages on this change');
             done();
           });
           element._serverConfig = {change: {update_delay: 12345}};
@@ -1315,5 +1379,28 @@
       assert.equal(Gerrit.Nav.getEditUrlForDiff.lastCall.args[1], 'foo');
       assert.isTrue(Gerrit.Nav.navigateToRelativeUrl.called);
     });
+
+    suite('plugin endpoints', () => {
+      test('endpoint params', done => {
+        element._change = {labels: {}};
+        element._currentRevision = {};
+        let hookEl;
+        let plugin;
+        Gerrit.install(
+            p => {
+              plugin = p;
+              plugin.hook('change-view-integration').getLastAttached().then(
+                  el => hookEl = el);
+            },
+            '0.1',
+            'http://some/plugins/url.html');
+        flush(() => {
+          assert.strictEqual(hookEl.plugin, plugin);
+          assert.strictEqual(hookEl.change, element._change);
+          assert.strictEqual(hookEl.revision, element._currentRevision);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index d3bf159..a18e2a2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -55,8 +55,8 @@
         confirm-label="Abandon"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Abandon Change</div>
-      <div class="main">
+      <div class="header" slot="header">Abandon Change</div>
+      <div class="main" slot="main">
         <label for="messageInput">Abandon Message</label>
         <iron-autogrow-textarea
             id="messageInput"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index 5151280..34688ad 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -62,8 +62,8 @@
         confirm-label="Cherry Pick"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Cherry Pick Change to Another Branch</div>
-      <div class="main">
+      <div class="header" slot="header">Cherry Pick Change to Another Branch</div>
+      <div class="main" slot="main">
         <label for="branchInput">
           Cherry Pick to branch
         </label>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
index ec6bfb4..2e530d9 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
@@ -61,8 +61,8 @@
         confirm-label="Move Change"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Move Change to Another Branch</div>
-      <div class="main">
+      <div class="header" slot="header">Move Change to Another Branch</div>
+      <div class="main" slot="main">
         <p class="warning">
           Warning: moving a change will not change its parents.
         </p>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index 2772594..582a03c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -52,8 +52,8 @@
         confirm-label="Rebase"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Confirm rebase</div>
-      <div class="main">
+      <div class="header" slot="header">Confirm rebase</div>
+      <div class="main" slot="main">
         <div id="rebaseOnParent" class="rebaseOption"
             hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]">
           <input id="rebaseOnParentInput"
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 92e8de3..5b39547 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -50,8 +50,8 @@
         confirm-label="Revert"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Revert Merged Change</div>
-      <div class="main">
+      <div class="header" slot="header">Revert Merged Change</div>
+      <div class="main" slot="main">
         <label for="messageInput">
           Revert Commit Message
         </label>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index c8f724d..f46af09 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -21,6 +21,7 @@
 <link rel="import" href="../../diff/gr-patch-range-select/gr-patch-range-select.html">
 <link rel="import" href="../../edit/gr-edit-controls/gr-edit-controls.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -100,6 +101,9 @@
       gr-button.selected iron-icon {
         color: var(--color-link);
       }
+      gr-linked-chip {
+        --linked-chip-text-color: black;
+      }
       .expanded #collapseBtn,
       .openFile .fileViewActions {
         align-items: center;
@@ -145,7 +149,7 @@
           <h3 class="label">Files</h3>
           <gr-patch-range-select
               id="rangeSelect"
-              comments="[[comments]]"
+              change-comments="[[changeComments]]"
               change-num="[[changeNum]]"
               patch-num="[[patchNum]]"
               base-patch-num="[[basePatchNum]]"
@@ -164,14 +168,28 @@
           </span>
           <span class="container descriptionContainer hideOnEdit">
             <span class="separator"></span>
-            <gr-editable-label
-                id="descriptionLabel"
-                class="descriptionLabel"
-                label-text="Add patchset description"
-                value="[[_computePatchSetDescription(change, patchNum)]]"
-                placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-                read-only="[[_descriptionReadOnly]]"
-                on-changed="_handleDescriptionChanged"></gr-editable-label>
+            <template
+                is="dom-if"
+                if="[[_patchsetDescription]]">
+              <gr-linked-chip
+                  id="descriptionChip"
+                  text="[[_patchsetDescription]]"
+                  removable="[[!_descriptionReadOnly]]"
+                  on-remove="_handleDescriptionRemoved"></gr-linked-chip>
+            </template>
+            <template
+                is="dom-if"
+                if="[[!_patchsetDescription]]">
+              <gr-editable-label
+                  id="descriptionLabel"
+                  uppercase
+                  class="descriptionLabel"
+                  label-text="Add patchset description"
+                  value="[[_patchsetDescription]]"
+                  placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+                  read-only="[[_descriptionReadOnly]]"
+                  on-changed="_handleDescriptionChanged"></gr-editable-label>
+            </template>
           </span>
         </div>
         <div class$="rightControls [[_computeExpandedClass(filesExpanded)]]">
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
index 07db340..123b156 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -27,7 +27,7 @@
       change: Object,
       changeNum: String,
       changeUrl: String,
-      comments: Object,
+      changeComments: Object,
       commitInfo: Object,
       editLoaded: Boolean,
       loggedIn: Boolean,
@@ -48,6 +48,10 @@
         readOnly: true,
         value: 225,
       },
+      _patchsetDescription: {
+        type: String,
+        value: '',
+      },
       _descriptionReadOnly: {
         type: Boolean,
         computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
@@ -67,6 +71,10 @@
       Gerrit.PatchSetBehavior,
     ],
 
+    observers: [
+      '_computePatchSetDescription(change, patchNum)',
+    ],
+
     _expandAllDiffs() {
       this._expanded = true;
       this.fire('expand-diffs');
@@ -111,10 +119,14 @@
 
     _computePatchSetDescription(change, patchNum) {
       const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
-      return (rev && rev.description) ?
+      this._patchsetDescription = (rev && rev.description) ?
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
+    _handleDescriptionRemoved(e) {
+      return this._updateDescription('', e);
+    },
+
     /**
      * @param {!Object} revisions The revisions object keyed by revision hashes
      * @param {?Object} patchSet A revision already fetched from {revisions}
@@ -131,15 +143,31 @@
 
     _handleDescriptionChanged(e) {
       const desc = e.detail.trim();
+      this._updateDescription(desc, e);
+    },
+
+    /**
+     * Update the patchset description with the rest API.
+     * @param {string} desc
+     * @param {?(Event|Node)} e
+     * @return {!Promise}
+     */
+    _updateDescription(desc, e) {
+      const target = Polymer.dom(e).rootTarget;
+      if (target) { target.disabled = true; }
       const rev = this.getRevisionByPatchNum(this.change.revisions,
           this.patchNum);
       const sha = this._getPatchsetHash(this.change.revisions, rev);
-      this.$.restAPI.setDescription(this.changeNum,
-          this.patchNum, desc)
+      return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
           .then(res => {
             if (res.ok) {
+              if (target) { target.disabled = false; }
               this.set(['_change', 'revisions', sha, 'description'], desc);
+              this._patchsetDescription = desc;
             }
+          }).catch(err => {
+            if (target) { target.disabled = false; }
+            return;
           });
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
index d47023f..8dbb06f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -96,10 +96,9 @@
           'Add patchset description');
     });
 
-    test('_handleDescriptionChanged', () => {
+    test('description editing', () => {
       const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
           .returns(Promise.resolve({ok: true}));
-      sandbox.stub(element, '_computeDescriptionReadOnly');
 
       element.changeNum = '42';
       element.basePatchNum = 'PARENT';
@@ -120,14 +119,44 @@
       element.loggedIn = true;
 
       flushAsynchronousOperations();
-      const label = element.$.descriptionLabel;
-      assert.equal(label.value, 'test');
-      label.editing = true;
-      label._inputText = 'test2';
-      label._save();
-      flushAsynchronousOperations();
-      assert.isTrue(putDescStub.called);
-      assert.equal(putDescStub.args[0][2], 'test2');
+
+      // The element has a description, so the account chip should be visible
+      // and the description label should not exist.
+      const chip = Polymer.dom(element.root).querySelector('#descriptionChip');
+      let label = Polymer.dom(element.root).querySelector('#descriptionLabel');
+
+      assert.equal(chip.text, 'test');
+      assert.isNotOk(label);
+
+      // Simulate tapping the remove button, but call function directly so that
+      // can determine what happens after the promise is resolved.
+      return element._handleDescriptionRemoved().then(() => {
+        // The API stub should be called with an empty string for the new
+        // description.
+        assert.equal(putDescStub.lastCall.args[2], '');
+
+        flushAsynchronousOperations();
+        // The editable label should now be visible and the chip hidden.
+        label = Polymer.dom(element.root).querySelector('#descriptionLabel');
+        assert.isOk(label);
+        assert.equal(getComputedStyle(chip).display, 'none');
+        assert.notEqual(getComputedStyle(label).display, 'none');
+        assert.isFalse(label.readOnly);
+        // Edit the label to have a new value of test2, and save.
+        label.editing = true;
+        label._inputText = 'test2';
+        label._save();
+        flushAsynchronousOperations();
+        // The API stub should be called with an `test2` for the new
+        // description.
+        assert.equal(putDescStub.callCount, 2);
+        assert.equal(putDescStub.lastCall.args[2], 'test2');
+      }).then(() => {
+        flushAsynchronousOperations();
+        // The chip should be visible again, and the label hidden.
+        assert.equal(getComputedStyle(label).display, 'none');
+        assert.notEqual(getComputedStyle(chip).display, 'none');
+      });
     });
 
     test('expandAllDiffs called when expand button clicked', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index c2b3f87..ae12592 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -29,6 +29,7 @@
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
 <link rel="import" href="../gr-file-list-constants.html">
 
@@ -266,17 +267,16 @@
           </span>
           <div class="comments desktop">
             <span class="drafts">
-              [[_computeDraftsString(changeComments.drafts, patchRange.patchNum, file.__path)]]
+              [[_computeDraftsString(changeComments, patchRange.patchNum, file.__path)]]
             </span>
-            [[_computeCommentsString(changeComments.comments, patchRange.patchNum, file.__path)]]
-            [[_computeUnresolvedString(changeComments.comments, changeComments.drafts, patchRange.patchNum, file.__path)]]
+            [[_computeCommentsString(changeComments, patchRange.patchNum, file.__path)]]
           </div>
           <div class="comments mobile">
             <span class="drafts">
-              [[_computeDraftsStringMobile(changeComments.drafts, patchRange.patchNum,
+              [[_computeDraftsStringMobile(changeComments, patchRange.patchNum,
                   file.__path)]]
             </span>
-            [[_computeCommentsStringMobile(changeComments.comments, patchRange.patchNum,
+            [[_computeCommentsStringMobile(changeComments, patchRange.patchNum,
                 file.__path)]]
           </div>
           <div class$="[[_computeClass('stats', file.__path)]]">
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index cc1ee7d..4f7f89e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -309,89 +309,67 @@
       this.$.diffCursor.handleDiffUpdate();
     },
 
-    _computeCommentsString(comments, patchNum, path) {
-      return this._computeCountString(comments, patchNum, path, 'comment');
-    },
-
-    _computeDraftsString(drafts, patchNum, path) {
-      return this._computeCountString(drafts, patchNum, path, 'draft');
-    },
-
-    _computeDraftsStringMobile(drafts, patchNum, path) {
-      const draftCount = this._computeCountString(drafts, patchNum, path);
-      return draftCount ? draftCount + 'd' : '';
-    },
-
-    _computeCommentsStringMobile(comments, patchNum, path) {
-      const commentCount = this._computeCountString(comments, patchNum, path);
-      return commentCount ? commentCount + 'c' : '';
-    },
-
-    getCommentsForPath(comments, patchNum, path) {
-      return (comments[path] || []).filter(c => {
-        return this.patchNumEquals(c.patch_set, patchNum);
-      });
-    },
-
     /**
-     * @param {!Array} comments
-     * @param {number} patchNum
-     * @param {string} path
-     * @param {string=} opt_noun
-     */
-    _computeCountString(comments, patchNum, path, opt_noun) {
-      if (!comments) { return ''; }
-
-      const patchComments = this.getCommentsForPath(comments, patchNum, path);
-      const num = patchComments.length;
-      if (num === 0) { return ''; }
-      if (!opt_noun) { return num; }
-      const output = num + ' ' + opt_noun + (num > 1 ? 's' : '');
-      return output;
-    },
-
-    /**
-     * Computes a string counting the number of unresolved comment threads in a
-     * given file and path.
+     * Computes a string with the number of comments and unresolved comments.
      *
-     * @param {!Object} comments
-     * @param {!Object} drafts
+     * @param {!Object} changeComments
      * @param {number} patchNum
      * @param {string} path
      * @return {string}
      */
-    _computeUnresolvedString(comments, drafts, patchNum, path) {
-      const unresolvedNum = this.computeUnresolvedNum(
-          comments, drafts, patchNum, path);
-      return unresolvedNum === 0 ? '' : '(' + unresolvedNum + ' unresolved)';
+    _computeCommentsString(changeComments, patchNum, path) {
+      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
+          path);
+      const commentCount = changeComments.computeCommentCount(patchNum, path);
+      const commentString = GrCountStringFormatter.computePluralString(
+          commentCount, 'comment');
+      const unresolvedString = GrCountStringFormatter.computeString(
+          unresolvedCount, 'unresolved');
+
+      return commentString +
+          // Add a space if both comments and unresolved
+          (commentString && unresolvedString ? ' ' : '') +
+          // Add parentheses around unresolved if it exists.
+          (unresolvedString ? `(${unresolvedString})` : '');
     },
 
-    computeUnresolvedNum(comments, drafts, patchNum, path) {
-      comments = this.getCommentsForPath(comments, patchNum, path);
-      drafts = this.getCommentsForPath(drafts, patchNum, path);
-      comments = comments.concat(drafts);
+    /**
+     * Computes a string with the number of drafts.
+     *
+     * @param {!Object} changeComments
+     * @param {number} patchNum
+     * @param {string} path
+     * @return {string}
+     */
+    _computeDraftsString(changeComments, patchNum, path) {
+      const draftCount = changeComments.computeDraftCount(patchNum, path);
+      return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+    },
 
-      // Create an object where every comment ID is the key of an unresolved
-      // comment.
+    /**
+     * Computes a shortened string with the number of drafts.
+     *
+     * @param {!Object} changeComments
+     * @param {number} patchNum
+     * @param {string} path
+     * @return {string}
+     */
+    _computeDraftsStringMobile(changeComments, patchNum, path) {
+      const draftCount = changeComments.computeDraftCount(patchNum, path);
+      return GrCountStringFormatter.computeShortString(draftCount, 'd');
+    },
 
-      const idMap = comments.reduce((acc, comment) => {
-        if (comment.unresolved) {
-          acc[comment.id] = true;
-        }
-        return acc;
-      }, {});
-
-      // Set false for the comments that are marked as parents.
-      for (const comment of comments) {
-        idMap[comment.in_reply_to] = false;
-      }
-
-      // The unresolved comments are the comments that still have true.
-      const unresolvedLeaves = Object.keys(idMap).filter(key => {
-        return idMap[key];
-      });
-
-      return unresolvedLeaves.length;
+    /**
+     * Computes a shortened string with the number of comments.
+     *
+     * @param {!Object} changeComments
+     * @param {number} patchNum
+     * @param {string} path
+     * @return {string}
+     */
+    _computeCommentsStringMobile(changeComments, patchNum, path) {
+      const commentCount = changeComments.computeCommentCount(patchNum, path);
+      return GrCountStringFormatter.computeShortString(commentCount, 'c');
     },
 
     _computeReviewed(file, _reviewed) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index cb463c0..5a33c01 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -348,6 +348,135 @@
       }
     });
 
+    test('comment filtering', () => {
+      element.changeComments._comments = {
+        '/COMMIT_MSG': [
+          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
+          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
+        ],
+        'myfile.txt': [
+          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
+          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
+          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 2,
+            message: 'wat!?',
+            updated: '2017-02-09 16:40:49',
+            id: '1',
+            unresolved: true,
+          },
+          {
+            patch_set: 2,
+            message: 'hi',
+            updated: '2017-02-10 16:40:49',
+            id: '2',
+            in_reply_to: '1',
+            unresolved: false,
+          },
+          {
+            patch_set: 2,
+            message: 'good news!',
+            updated: '2017-02-08 16:40:49',
+            id: '3',
+            unresolved: true,
+          },
+        ],
+      };
+      element.changeComments._drafts = {
+        '/COMMIT_MSG': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-15 16:40:49',
+            id: '5',
+            unresolved: true,
+          },
+          {
+            patch_set: 1,
+            message: 'fyi',
+            updated: '2017-02-15 16:40:49',
+            id: '6',
+            unresolved: false,
+          },
+        ],
+        'unresolved.file': [
+          {
+            patch_set: 1,
+            message: 'hi',
+            updated: '2017-02-11 16:40:49',
+            id: '4',
+            unresolved: false,
+          },
+        ],
+      };
+
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '1',
+              '/COMMIT_MSG', 'comment'), '2 comments (1 unresolved)');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '1'
+          , '/COMMIT_MSG'), '2c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              'unresolved.file'), '1 draft');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              'unresolved.file'), '1d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '1',
+              'myfile.txt', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '1',
+              'myfile.txt'), '1c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '1',
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '1',
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              'file_added_in_rev2.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '2',
+              '/COMMIT_MSG', 'comment'), '1 comment');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '2',
+              '/COMMIT_MSG'), '1c');
+      assert.equal(
+          element._computeDraftsString(element.changeComments, '1',
+              '/COMMIT_MSG'), '2 drafts');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '1',
+              '/COMMIT_MSG'), '2d');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '2',
+              'myfile.txt', 'comment'), '2 comments');
+      assert.equal(
+          element._computeCommentsStringMobile(element.changeComments, '2',
+              'myfile.txt'), '2c');
+      assert.equal(
+          element._computeDraftsStringMobile(element.changeComments, '2',
+              'myfile.txt'), '');
+      assert.equal(
+          element._computeCommentsString(element.changeComments, '2',
+              'file_added_in_rev2.txt', 'comment'), '');
+      assert.equal(element._computeCommentsString(element.changeComments, '2',
+          'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+    });
+
     suite('keyboard shortcuts', () => {
       setup(() => {
         element._files = [
@@ -526,119 +655,6 @@
       });
     });
 
-    test('comment filtering', () => {
-      const comments = {
-        '/COMMIT_MSG': [
-          {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
-          {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
-        ],
-        'myfile.txt': [
-          {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
-          {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
-          {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
-        ],
-        'unresolved.file': [
-          {
-            patch_set: 2,
-            message: 'wat!?',
-            updated: '2017-02-09 16:40:49',
-            id: '1',
-            unresolved: true,
-          },
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-10 16:40:49',
-            id: '2',
-            in_reply_to: '1',
-            unresolved: false,
-          },
-          {
-            patch_set: 2,
-            message: 'good news!',
-            updated: '2017-02-08 16:40:49',
-            id: '3',
-            unresolved: true,
-          },
-        ],
-      };
-      const drafts = {
-        'unresolved.file': [
-          {
-            patch_set: 2,
-            message: 'hi',
-            updated: '2017-02-11 16:40:49',
-            id: '4',
-            in_reply_to: '3',
-            unresolved: false,
-          },
-        ],
-      };
-      assert.equal(
-          element._computeCountString(comments, '1', '/COMMIT_MSG', 'comment'),
-          '2 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '1', '/COMMIT_MSG'),
-          '2c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '1', '/COMMIT_MSG'),
-          '2d');
-      assert.equal(
-          element._computeCountString(comments, '1', 'myfile.txt', 'comment'),
-          '1 comment');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '1', 'myfile.txt'),
-          '1c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '1', 'myfile.txt'),
-          '1d');
-      assert.equal(
-          element._computeCountString(comments, '1',
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '1',
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '1',
-              'file_added_in_rev2.txt'), '');
-      assert.equal(
-          element._computeCountString(comments, '2', '/COMMIT_MSG', 'comment'),
-          '1 comment');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '2', '/COMMIT_MSG'),
-          '1c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '2', '/COMMIT_MSG'),
-          '1d');
-      assert.equal(
-          element._computeCountString(comments, '2', 'myfile.txt', 'comment'),
-          '2 comments');
-      assert.equal(
-          element._computeCommentsStringMobile(comments, '2', 'myfile.txt'),
-          '2c');
-      assert.equal(
-          element._computeDraftsStringMobile(comments, '2', 'myfile.txt'),
-          '2d');
-      assert.equal(
-          element._computeCountString(comments, '2',
-              'file_added_in_rev2.txt', 'comment'), '');
-      assert.equal(element._computeCountString(comments, '2',
-          'unresolved.file', 'comment'), '3 comments');
-      assert.equal(
-          element._computeUnresolvedString(comments, [], 2, 'myfile.txt'), '');
-      assert.equal(
-          element.computeUnresolvedNum(comments, [], 2, 'myfile.txt'), 0);
-      assert.equal(
-          element._computeUnresolvedString(comments, [], 2, 'unresolved.file'),
-          '(1 unresolved)');
-      assert.equal(
-          element.computeUnresolvedNum(comments, [], 2, 'unresolved.file'), 1);
-      assert.equal(
-          element._computeUnresolvedString(comments, drafts, 2,
-              'unresolved.file'), '');
-    });
-
     test('computed properties', () => {
       assert.equal(element._computeFileStatus('A'), 'A');
       assert.equal(element._computeFileStatus(undefined), 'M');
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index e04eeb7..e93e710 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -93,6 +93,12 @@
       gr-account-chip {
         display: inline;
       }
+      gr-button {
+        --gr-button: {
+          padding-left: 0;
+          padding-right: 0;
+        }
+      }
       .collapsed gr-comment-list,
       .collapsed .replyContainer,
       .collapsed .hideOnCollapsed,
@@ -159,7 +165,7 @@
                 no-trailing-margin
                 class="message hideOnCollapsed"
                 content="[[message.message]]"
-                config="[[_commentLinks]]"></gr-formatted-text>
+                config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
             <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
               <gr-button link small on-tap="_handleReplyTap">Reply</gr-button>
             </div>
@@ -168,7 +174,7 @@
                 change-num="[[changeNum]]"
                 patch-num="[[message._revision_number]]"
                 project-name="[[projectName]]"
-                comment-links="[[_commentLinks]]"></gr-comment-list>
+                project-config="[[_projectConfig]]"></gr-comment-list>
           </div>
         </template>
         <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index d907f3b..6b4499f 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -79,11 +79,10 @@
         type: String,
         observer: '_projectNameChanged',
       },
-      _commentLinks: Object,
       /**
        * @type {{ commentlinks: Array }}
        */
-      projectConfig: Object,
+      _projectConfig: Object,
       // Computed property needed to trigger Polymer value observing.
       _expanded: {
         type: Object,
@@ -239,7 +238,7 @@
 
     _projectNameChanged(name) {
       this.$.restAPI.getProjectConfig(name).then(config => {
-        this._commentLinks = config.commentlinks;
+        this._projectConfig = config;
       });
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 401aaf8..db36438 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -57,6 +57,7 @@
       h4:before,
       section div:before {
         content: ' ';
+        flex-shrink: 0;
         width: 1.2em
       }
       .relatedChanges a {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 780dca2..0143196 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -76,6 +76,12 @@
     clear() {
       this.loading = true;
       this.hidden = true;
+
+      this._relatedResponse = {changes: []};
+      this._submittedTogether = [];
+      this._conflicts = [];
+      this._cherryPicks = [];
+      this._sameTopic = [];
     },
 
     reload() {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index df4391e..c035b44 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -337,12 +337,37 @@
           true);
     });
 
-    test('clear hides', () => {
+    test('clear and empties', () => {
+      const changes = [{
+        project: 'foo/bar',
+        change_id: 'Ideadbeef',
+        commit: {
+          commit: 'deadbeef',
+          parents: [{commit: 'abc123'}],
+          author: {},
+          subject: 'do that thing',
+        },
+        _change_number: 12345,
+        _revision_number: 1,
+        _current_revision_number: 1,
+        status: 'NEW',
+      }];
+      element._relatedResponse = {changes};
+      element._submittedTogether = changes;
+      element._conflicts = changes;
+      element._cherryPicks = changes;
+      element._sameTopic = changes;
+
       element.loading = false;
       element.hidden = false;
       element.clear();
       assert.isTrue(element.loading);
       assert.isTrue(element.hidden);
+      assert.equal(element._relatedResponse.changes.length, 0);
+      assert.equal(element._submittedTogether.length, 0);
+      assert.equal(element._conflicts.length, 0);
+      assert.equal(element._cherryPicks.length, 0);
+      assert.equal(element._sameTopic.length, 0);
     });
 
     test('update fires', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
index babd95c..22b53c8 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -86,7 +86,8 @@
         ],
       };
       element.serverConfig = {note_db_enabled: true};
-      sandbox.stub(element, 'fetchIsLatestKnown', () => Promise.resolve(true));
+      sandbox.stub(element, 'fetchChangeUpdates')
+          .returns(Promise.resolve({isLatest: true}));
     };
 
     setup(() => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index a1719da..6fabe89 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -237,16 +237,14 @@
 
     open(opt_focusTarget) {
       this.knownLatestState = LatestPatchState.CHECKING;
-      this.fetchIsLatestKnown(this.change, this.$.restAPI)
-          .then(isUpToDate => {
-            this.knownLatestState = isUpToDate ?
+      this.fetchChangeUpdates(this.change, this.$.restAPI)
+          .then(result => {
+            this.knownLatestState = result.isLatest ?
                 LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
           });
 
       this._focusOn(opt_focusTarget);
-      if (!this.draft || !this.draft.length) {
-        this.draft = this._loadStoredDraft();
-      }
+      this.draft = this._loadStoredDraft();
       if (this.$.restAPI.hasPendingDiffDrafts()) {
         this._savingComments = true;
         this.$.restAPI.awaitPendingDiffDrafts().then(() => {
@@ -510,7 +508,8 @@
     },
 
     _focusOn(section) {
-      if (section === FocusTarget.ANY) {
+      // Safeguard- always want to focus on something.
+      if (!section || section === FocusTarget.ANY) {
         section = this._chooseFocusTarget();
       }
       if (section === FocusTarget.BODY) {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 278f2c6..b590f85 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -103,8 +103,8 @@
       eraseDraftCommentStub = sandbox.stub(element.$.storage,
           'eraseDraftComment');
 
-      sandbox.stub(element, 'fetchIsLatestKnown',
-          () => { return Promise.resolve(true); });
+      sandbox.stub(element, 'fetchChangeUpdates')
+          .returns(Promise.resolve({isLatest: true}));
 
       // Allow the elements created by dom-repeat to be stamped.
       flushAsynchronousOperations();
@@ -415,8 +415,18 @@
       assert.equal(element.draft, storedDraft);
     });
 
+    test('gets draft from storage even when text is already present', () => {
+      const storedDraft = 'hello world';
+      getDraftCommentStub.returns({message: storedDraft});
+      element.draft = 'foo bar';
+      element.open();
+      assert.isTrue(getDraftCommentStub.called);
+      assert.equal(element.draft, storedDraft);
+    });
+
     test('blank if no stored draft', () => {
       getDraftCommentStub.returns(null);
+      element.draft = 'foo bar';
       element.open();
       assert.isTrue(getDraftCommentStub.called);
       assert.equal(element.draft, '');
@@ -505,6 +515,45 @@
       assert.isFalse(filter({group: cc2}));
     });
 
+    test('_focusOn', () => {
+      sandbox.spy(element, '_chooseFocusTarget');
+      element.serverConfig = {note_db_enabled: true};
+      flushAsynchronousOperations();
+      const textareaStub = sandbox.stub(element.$.textarea, 'async');
+      const reviewerEntryStub = sandbox.stub(element.$.reviewers.focusStart,
+          'async');
+      const ccStub = sandbox.stub(element.$$('#ccs').focusStart, 'async');
+      element._focusOn();
+      assert.equal(element._chooseFocusTarget.callCount, 1);
+      assert.deepEqual(textareaStub.callCount, 1);
+      assert.deepEqual(reviewerEntryStub.callCount, 0);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.ANY);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 2);
+      assert.deepEqual(reviewerEntryStub.callCount, 0);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.BODY);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 3);
+      assert.deepEqual(reviewerEntryStub.callCount, 0);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.REVIEWERS);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 3);
+      assert.deepEqual(reviewerEntryStub.callCount, 1);
+      assert.deepEqual(ccStub.callCount, 0);
+
+      element._focusOn(element.FocusTarget.CCS);
+      assert.equal(element._chooseFocusTarget.callCount, 2);
+      assert.deepEqual(textareaStub.callCount, 3);
+      assert.deepEqual(reviewerEntryStub.callCount, 1);
+      assert.deepEqual(ccStub.callCount, 1);
+    });
+
     test('_chooseFocusTarget', () => {
       element._account = null;
       assert.strictEqual(
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 9a5b5ed..76da932 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -54,6 +54,12 @@
       .remove {
         font-size: .9em;
       }
+      gr-button {
+        --gr-button: {
+          padding-left: 0;
+          padding-right: 0;
+        }
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         gr-account-chip:first-of-type {
           margin-top: 0;
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index a935e26..0de1580 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -372,6 +372,10 @@
           detail: Gerrit.Nav.GroupDetailView.MEMBERS,
         });
       },
+
+      getUrlForSettings() {
+        return this._getUrlFor({view: Gerrit.Nav.View.SETTINGS});
+      },
     };
   })(window);
 </script>
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 8bc2a0b..c31855a 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -228,6 +228,8 @@
         url = this._generateDiffOrEditUrl(params);
       } else if (params.view === Views.GROUP) {
         url = this._generateGroupUrl(params);
+      } else if (params.view === Views.SETTINGS) {
+        url = this._generateSettingsUrl(params);
       } else {
         throw new Error('Can\'t generate');
       }
@@ -352,6 +354,14 @@
     },
 
     /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateSettingsUrl(params) {
+      return '/settings';
+    },
+
+    /**
      * Given an object of parameters, potentially including a `patchNum` or a
      * `basePatchNum` or both, return a string representation of that range. If
      * no range is indicated in the params, the empty string is returned.
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index ba7aad3..1ba44eb 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -335,8 +335,9 @@
     },
 
     _handleForwardSlashKey(e) {
+      const keyboardEvent = this.getKeyboardEvent(e);
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
 
       e.preventDefault();
       this.$.searchInput.focus();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
index 303734a..209e8c9 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -76,6 +76,10 @@
 
   ChangeComments.prototype._patchNumEquals =
       Gerrit.PatchSetBehavior.patchNumEquals;
+  ChangeComments.prototype._isMergeParent =
+      Gerrit.PatchSetBehavior.isMergeParent;
+  ChangeComments.prototype._getParentIndex =
+      Gerrit.PatchSetBehavior.getParentIndex;
 
   /**
    * Get an object mapping file paths to a boolean representing whether that
@@ -299,6 +303,14 @@
   * @return {boolean}
   */
   ChangeComments.prototype._isInBaseOfPatchRange = function(comment, range) {
+    // If the base of the patch range is a parent of a merge, and the comment
+    // appears on a specific parent then only show the comment if the parent
+    // index of the comment matches that of the range.
+    if (comment.parent && comment.side === PARENT) {
+      return this._isMergeParent(range.basePatchNum) &&
+          comment.parent === this._getParentIndex(range.basePatchNum);
+    }
+
     // If the base of the range is the parent of the patch:
     if (range.basePatchNum === PARENT &&
         comment.side === PARENT &&
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
index 79056b1..b6e488f 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -174,6 +174,16 @@
         comment.patch_set = 2;
         assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
             patchRange));
+
+        patchRange.basePatchNum = -2;
+        comment.side = PARENT;
+        comment.parent = 1;
+        assert.isFalse(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
+
+        comment.parent = 2;
+        assert.isTrue(element._changeComments._isInBaseOfPatchRange(comment,
+            patchRange));
       });
 
       test('_isInRevisionOfPatchRange', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
index 999c883..8cefabb 100644
--- a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
+++ b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -55,8 +55,8 @@
         confirm-label="Delete"
         on-confirm="_handleConfirmTap"
         on-cancel="_handleCancelTap">
-      <div class="header">Delete Comment</div>
-      <div class="main">
+      <div class="header" slot="header">Delete Comment</div>
+      <div class="main" slot="main">
         <label for="messageInput">Enter comment delete reason</label>
         <iron-autogrow-textarea
             id="messageInput"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index 5eb0a5d..62527ca 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -333,10 +333,10 @@
           confirm-label="Discard"
           on-confirm="_handleConfirmDiscard"
           on-cancel="_closeConfirmDiscardOverlay">
-        <div class="header">
+        <div class="header" slot="header">
           Discard comment
         </div>
-        <div class="main">
+        <div class="main" slot="main">
           Are you sure you want to discard this draft comment?
         </div>
       </gr-confirm-dialog>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 2490509..03380e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -251,6 +251,22 @@
       };
     },
 
+    /**
+     * The only line in which add a comment tooltip is cut off is the first
+     * line. Even if there is a collapsed section, The first visible line is
+     * in the position where the second line would have been, if not for the
+     * collapsed section, so don't need to worry about this case for
+     * positioning the tooltip.
+     */
+    _positionActionBox(actionBox, startLine, range) {
+      if (startLine > 1) {
+        actionBox.placeAbove(range);
+        return;
+      }
+      actionBox.positionBelow = true;
+      actionBox.placeBelow(range);
+    },
+
     _handleSelection() {
       const normalizedRange = this._getNormalizedRange();
       if (!normalizedRange) {
@@ -285,17 +301,18 @@
       };
       actionBox.side = start.side;
       if (start.line === end.line) {
-        actionBox.placeAbove(domRange);
+        this._positionActionBox(actionBox, start.line, domRange);
       } else if (start.node instanceof Text) {
         if (start.column) {
-          actionBox.placeAbove(start.node.splitText(start.column));
+          this._positionActionBox(actionBox, start.line,
+              start.node.splitText(start.column));
         }
         start.node.parentElement.normalize(); // Undo splitText from above.
       } else if (start.node.classList.contains('content') &&
-                 start.node.firstChild) {
-        actionBox.placeAbove(start.node.firstChild);
+          start.node.firstChild) {
+        this._positionActionBox(actionBox, start.line, start.node.firstChild);
       } else {
-        actionBox.placeAbove(start.node);
+        this._positionActionBox(actionBox, start.line, start.node);
       }
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index b63b9a4..4bbf12b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -38,6 +38,27 @@
       <table id="diffTable">
 
         <tbody class="section both">
+           <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
+            <td class="right lineNum" data-value="1"></td>
+            <td class="content both"><div class="contentText">[1] Nam cum ad me in Cumanum salutandi causa uterque</div></td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="remove" right-type="add">
+            <td class="left lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">	</span>quod <hl>Epicurum</hl></div></td>
+            <td class="right lineNum" data-value="2"></td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add"><div class="contentText">nacti , <hl>,</hl> sumus <hl><span class="tab-indicator" style="tab-size:8;">	</span></hl> otiosum,  <span class="tab-indicator" style="tab-size:8;">	</span> audiam,  sit, quod</div></td>
+          </tr>
+        </tbody>
+
+
+        <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
             <td class="left lineNum" data-value="138"></td>
             <td class="content both"><div class="contentText">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</div></td>
@@ -253,6 +274,7 @@
         contentStubs = [];
         stub('gr-selection-action-box', {
           placeAbove: sandbox.stub(),
+          placeBelow: sandbox.stub(),
         });
         diff = element.querySelector('#diffTable');
         builder = {
@@ -270,9 +292,29 @@
         window.getSelection().removeAllRanges();
       });
 
+      test('single first line', () => {
+        const content = stubContent(1, 'right');
+        sandbox.spy(element, '_positionActionBox');
+        emulateSelection(content.firstChild, 5, content.firstChild, 12);
+        const actionBox = element.$$('gr-selection-action-box');
+        assert.isTrue(actionBox.positionBelow);
+      });
+
+      test('multiline starting on first line', () => {
+        const startContent = stubContent(1, 'right');
+        const endContent = stubContent(2, 'right');
+        sandbox.spy(element, '_positionActionBox');
+        emulateSelection(
+            startContent.firstChild, 10, endContent.lastChild, 7);
+        const actionBox = element.$$('gr-selection-action-box');
+        assert.isTrue(actionBox.positionBelow);
+      });
+
       test('single line', () => {
         const content = stubContent(138, 'left');
+        sandbox.spy(element, '_positionActionBox');
         emulateSelection(content.firstChild, 5, content.firstChild, 12);
+        const actionBox = element.$$('gr-selection-action-box');
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
           startLine: 138,
@@ -281,14 +323,18 @@
           endChar: 12,
         });
         assert.equal(getActionSide(), 'left');
+        assert.notOk(actionBox.positionBelow);
       });
 
       test('multiline', () => {
         const startContent = stubContent(119, 'right');
         const endContent = stubContent(120, 'right');
+        sandbox.spy(element, '_positionActionBox');
         emulateSelection(
             startContent.firstChild, 10, endContent.lastChild, 7);
         assert.isTrue(element.isRangeSelected());
+        const actionBox = element.$$('gr-selection-action-box');
+
         assert.deepEqual(getActionRange(), {
           startLine: 119,
           startChar: 10,
@@ -296,6 +342,7 @@
           endChar: 36,
         });
         assert.equal(getActionSide(), 'right');
+        assert.notOk(actionBox.positionBelow);
       });
 
       test('multiple ranges aka firefox implementation', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 1dcf25e..48b6964 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -195,7 +195,7 @@
           <div class="jumpToFileContainer">
             <gr-dropdown-list
                 id="dropdown"
-                value="[[computeDisplayPath(_path)]]"
+                value="[[_path]]"
                 on-value-change="_handleFileChange"
                 items="[[_formattedFiles]]"
                 initial-count="75">
@@ -221,7 +221,7 @@
           <gr-patch-range-select
               id="rangeSelect"
               change-num="[[_changeNum]]"
-              comments="[[_changeComments.comments]]"
+              change-comments="[[_changeComments]]"
               patch-num="[[_patchRange.patchNum]]"
               base-patch-num="[[_patchRange.basePatchNum]]"
               files-weblinks="[[_filesWeblinks]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index cae8cbc..953ae1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -79,7 +79,8 @@
       // element for selected a file to view.
       _formattedFiles: {
         type: Array,
-        computed: '_formatFilesForDropdown(_fileList)',
+        computed: '_formatFilesForDropdown(_fileList, _patchRange.patchNum, ' +
+            '_changeComments)',
       },
       // An sorted array of files, as returned by the rest API.
       _fileList: {
@@ -641,19 +642,37 @@
       return this._getChangePath(change, patchRangeRecord.base, revisions);
     },
 
-    _formatFilesForDropdown(fileList) {
+    _formatFilesForDropdown(fileList, patchNum, changeComments) {
       if (!fileList) { return; }
       const dropdownContent = [];
       for (const path of fileList) {
         dropdownContent.push({
           text: this.computeDisplayPath(path),
           mobileText: this.computeTruncatedPath(path),
-          value: this.computeDisplayPath(path),
+          value: path,
+          bottomText: this._computeCommentString(changeComments, patchNum,
+              path),
         });
       }
       return dropdownContent;
     },
 
+    _computeCommentString(changeComments, patchNum, path) {
+      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum,
+          path);
+      const commentCount = changeComments.computeCommentCount(patchNum, path);
+      const commentString = GrCountStringFormatter.computePluralString(
+          commentCount, 'comment');
+      const unresolvedString = GrCountStringFormatter.computeString(
+          unresolvedCount, 'unresolved');
+
+      return commentString +
+          // Add a space if both comments and unresolved
+          (commentString && unresolvedString ? ', ' : '') +
+          // Add parentheses around unresolved if it exists.
+          (unresolvedString ? `${unresolvedString}` : '');
+    },
+
     _computePrefsButtonHidden(prefs, loggedIn) {
       return !loggedIn || !prefs;
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index ca06b8d..5e23ea3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -315,6 +315,34 @@
       assert.isTrue(overlayOpenStub.called);
     });
 
+    test('_computeCommentString', done => {
+      loadCommentSpy = sandbox.spy(element.$.commentAPI, 'loadAll');
+      const path = '/test';
+      element.$.commentAPI.loadAll().then(comments => {
+        const commentCountStub =
+            sandbox.stub(comments, 'computeCommentCount');
+        const unresolvedCountStub =
+            sandbox.stub(comments, 'computeUnresolvedNum');
+        commentCountStub.withArgs(1, path).returns(0);
+        commentCountStub.withArgs(2, path).returns(1);
+        commentCountStub.withArgs(3, path).returns(2);
+        commentCountStub.withArgs(4, path).returns(0);
+        unresolvedCountStub.withArgs(1, path).returns(1);
+        unresolvedCountStub.withArgs(2, path).returns(0);
+        unresolvedCountStub.withArgs(3, path).returns(2);
+        unresolvedCountStub.withArgs(4, path).returns(0);
+
+        assert.equal(element._computeCommentString(comments, 1, path),
+            '1 unresolved');
+        assert.equal(element._computeCommentString(comments, 2, path),
+            '1 comment');
+        assert.equal(element._computeCommentString(comments, 3, path),
+            '2 comments, 2 unresolved');
+        assert.equal(element._computeCommentString(comments, 4, path), '');
+        done();
+      });
+    });
+
     suite('url params', () => {
       setup(() => {
         sandbox.stub(Gerrit.Nav, 'getUrlForDiff', (c, p, pn, bpn) => {
@@ -332,21 +360,37 @@
           patchNum: '10',
         };
         element._change = {_number: 42};
-        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md',
+          '/COMMIT_MSG', '/MERGE_LIST'];
         element._path = 'glados.txt';
         const expectedFormattedFiles = [
           {
             text: 'chell.go',
             mobileText: 'chell.go',
             value: 'chell.go',
+            bottomText: '',
           }, {
             text: 'glados.txt',
             mobileText: 'glados.txt',
             value: 'glados.txt',
+            bottomText: '',
           }, {
             text: 'wheatley.md',
             mobileText: 'wheatley.md',
             value: 'wheatley.md',
+            bottomText: '',
+          },
+          {
+            text: 'Commit message',
+            mobileText: 'Commit message',
+            value: '/COMMIT_MSG',
+            bottomText: '',
+          },
+          {
+            text: 'Merge list',
+            mobileText: 'Merge list',
+            value: '/MERGE_LIST',
+            bottomText: '',
           },
         ];
 
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index a11902e..7fd97ad 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
-
+<link rel="import" href="../../shared/gr-count-string-formatter/gr-count-string-formatter.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 
 <dom-module id="gr-patch-range-select">
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 4e7fa38..9ac4451 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -34,21 +34,15 @@
       _baseDropdownContent: {
         type: Object,
         computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
-            '_sortedRevisions, comments)',
+            '_sortedRevisions, changeComments)',
       },
       _patchDropdownContent: {
         type: Object,
         computed: '_computePatchDropdownContent(availablePatches,' +
-            'basePatchNum, _sortedRevisions, comments)',
+            'basePatchNum, _sortedRevisions, changeComments)',
       },
       changeNum: String,
-      // In the case of a patch range select (like diff view) comments should
-      // be an empty array, so that the patch and base content computed values
-      // get triggered.
-      comments: {
-        type: Object,
-        value: () => { return {}; },
-      },
+      changeComments: Object,
       /** @type {{ meta_a: !Array, meta_b: !Array}} */
       filesWeblinks: Object,
       patchNum: String,
@@ -64,7 +58,7 @@
     behaviors: [Gerrit.PatchSetBehavior],
 
     _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
-        comments) {
+        changeComments) {
       const dropdownContent = [];
       for (const basePatch of availablePatches) {
         const basePatchNum = basePatch.num;
@@ -73,9 +67,9 @@
               basePatch.num, patchNum, _sortedRevisions),
           triggerText: `Patchset ${basePatchNum}`,
           text: `Patchset ${basePatchNum}` +
-              this._computePatchSetCommentsString(this.comments, basePatchNum),
-          mobileText: this._computeMobileText(basePatchNum, comments,
-              _sortedRevisions),
+              this._computePatchSetCommentsString(changeComments, basePatchNum),
+          mobileText: this._computeMobileText(basePatchNum,
+              changeComments, _sortedRevisions),
           bottomText: `${this._computePatchSetDescription(
               _sortedRevisions, basePatchNum)}`,
           value: basePatch.num,
@@ -88,14 +82,14 @@
       return dropdownContent;
     },
 
-    _computeMobileText(patchNum, comments, revisions) {
+    _computeMobileText(patchNum, changeComments, revisions) {
       return `${patchNum}` +
-          `${this._computePatchSetCommentsString(this.comments, patchNum)}` +
+          `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
           `${this._computePatchSetDescription(revisions, patchNum, true)}`;
     },
 
     _computePatchDropdownContent(availablePatches, basePatchNum,
-        _sortedRevisions, comments) {
+        _sortedRevisions, changeComments) {
       const dropdownContent = [];
       for (const patch of availablePatches) {
         const patchNum = patch.num;
@@ -106,8 +100,8 @@
               patchNum,
           text: `${patchNum === 'edit' ? '': 'Patchset '}${patchNum}` +
               `${this._computePatchSetCommentsString(
-                  this.comments, patchNum)}`,
-          mobileText: this._computeMobileText(patchNum, comments,
+                  changeComments, patchNum)}`,
+          mobileText: this._computeMobileText(patchNum, changeComments,
               _sortedRevisions),
           bottomText: `${this._computePatchSetDescription(
               _sortedRevisions, patchNum)}`,
@@ -151,66 +145,26 @@
           this.findSortedIndex(patchNum, sortedRevisions);
     },
 
-    // Copied from gr-file-list
-    // @todo(beckysiegel) clean up.
-    _getCommentsForPath(comments, patchNum, path) {
-      return (comments[path] || []).filter(c => {
-        return this.patchNumEquals(c.patch_set, patchNum);
-      });
-    },
 
-    // Copied from gr-file-list
-    // @todo(beckysiegel) clean up.
-    _computeUnresolvedNum(comments, drafts, patchNum, path) {
-      comments = this._getCommentsForPath(comments, patchNum, path);
-      drafts = this._getCommentsForPath(drafts, patchNum, path);
-      comments = comments.concat(drafts);
+    _computePatchSetCommentsString(changeComments, patchNum) {
+      if (!changeComments) { return; }
 
-      // Create an object where every comment ID is the key of an unresolved
-      // comment.
+      const commentCount = changeComments.computeCommentCount(patchNum);
+      const commentString = GrCountStringFormatter.computePluralString(
+          commentCount, 'comment');
 
-      const idMap = comments.reduce((acc, comment) => {
-        if (comment.unresolved) {
-          acc[comment.id] = true;
-        }
-        return acc;
-      }, {});
+      const unresolvedCount = changeComments.computeUnresolvedNum(patchNum);
+      const unresolvedString = GrCountStringFormatter.computeString(
+          unresolvedCount, 'unresolved');
 
-      // Set false for the comments that are marked as parents.
-      for (const comment of comments) {
-        idMap[comment.in_reply_to] = false;
+      if (!commentString.length && !unresolvedString.length) {
+        return '';
       }
 
-      // The unresolved comments are the comments that still have true.
-      const unresolvedLeaves = Object.keys(idMap).filter(key => {
-        return idMap[key];
-      });
-
-      return unresolvedLeaves.length;
-    },
-
-    _computePatchSetCommentsString(allComments, patchNum) {
-      // todo (beckysiegel) get comment strings for diff view also.
-      if (!allComments) { return ''; }
-      let numComments = 0;
-      let numUnresolved = 0;
-      for (const file in allComments) {
-        if (allComments.hasOwnProperty(file)) {
-          numComments += this._getCommentsForPath(
-              allComments, patchNum, file).length;
-          numUnresolved += this._computeUnresolvedNum(
-              allComments, {}, patchNum, file);
-        }
-      }
-      let commentsStr = '';
-      if (numComments > 0) {
-        commentsStr = ' (' + numComments + ' comments';
-        if (numUnresolved > 0) {
-          commentsStr += ', ' + numUnresolved + ' unresolved';
-        }
-        commentsStr += ')';
-      }
-      return commentsStr;
+      return ` (${commentString}` +
+          // Add a comma + space if both comments and unresolved
+          (commentString && unresolvedString ? ', ' : '') +
+          `${unresolvedString})`;
     },
 
     /**
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index cd7fe56..04f83bc 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -23,13 +23,24 @@
 <link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 
+<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-patch-range-select.html">
 
 <script>void(0);</script>
 
+<dom-module id="comment-api-mock">
+  <template>
+    <gr-patch-range-select id="patchRange" auto
+        change-comments="[[_changeComments]]"></gr-patch-range-select>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
+  </template>
+  <script src="../../diff/gr-comment-api/gr-comment-api-mock.js"></script>
+</dom-module>
+
 <test-fixture id="basic">
   <template>
-    <gr-patch-range-select auto></gr-patch-range-select>
+    <comment-api-mock></comment-api-mock>
   </template>
 </test-fixture>
 
@@ -37,10 +48,25 @@
   suite('gr-patch-range-select tests', () => {
     let element;
     let sandbox;
+    let commentApiWrapper;
 
     setup(() => {
-      element = fixture('basic');
       sandbox = sinon.sandbox.create();
+
+      stub('gr-rest-api-interface', {
+        getDiffComments() { return Promise.resolve({}); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve({}); },
+      });
+
+      // Element must be wrapped in an element with direct access to the
+      // comment API.
+      commentApiWrapper = fixture('basic');
+      element = commentApiWrapper.$.patchRange;
+
+      // Stub methods on the changeComments object after changeComments has
+      // been initalized.
+      return commentApiWrapper.loadComments();
     });
 
     teardown(() => sandbox.restore());
@@ -80,23 +106,12 @@
     });
 
     test('_computeBaseDropdownContent', () => {
-      const comments = {};
       const availablePatches = [
         {num: 'edit'},
         {num: 3},
         {num: 2},
         {num: 1},
       ];
-      const revisions = [
-        {
-          commit: {},
-          _number: 2,
-          description: 'description',
-        },
-        {commit: {}},
-        {commit: {}},
-        {commit: {}},
-      ];
       const patchNum = 1;
       const sortedRevisions = [
         {_number: 3},
@@ -143,7 +158,8 @@
         },
       ];
       assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
-          patchNum, sortedRevisions, revisions, comments), expectedResult);
+          patchNum, sortedRevisions, element.changeComments),
+          expectedResult);
     });
 
     test('_computeBaseDropdownContent called when patchNum updates', () => {
@@ -170,34 +186,32 @@
       assert.equal(element._computeBaseDropdownContent.callCount, 1);
     });
 
-    test('_computeBaseDropdownContent called when comments update', () => {
-      element.revisions = [
+    test('_computeBaseDropdownContent called when changeComments update',
+        done => {
+          element.revisions = [
         {commit: {}},
         {commit: {}},
         {commit: {}},
         {commit: {}},
-      ];
-      element.availablePatches = [
+          ];
+          element.availablePatches = [
         {num: 'edit'},
         {num: 3},
         {num: 2},
         {num: 1},
-      ];
-      element.patchNum = 2;
-      element.basePatchNum = 'PARENT';
-      flushAsynchronousOperations();
+          ];
+          element.patchNum = 2;
+          element.basePatchNum = 'PARENT';
+          flushAsynchronousOperations();
 
-      // Should be recomputed for each available patch
-      sandbox.stub(element, '_computeBaseDropdownContent');
-      assert.equal(element._computeBaseDropdownContent.callCount, 0);
-      element.set('comments', {
-        file: [{
-          message: 'test',
-          patch_set: 2,
-        }],
-      });
-      assert.equal(element._computeBaseDropdownContent.callCount, 1);
-    });
+          // Should be recomputed for each available patch
+          sandbox.stub(element, '_computeBaseDropdownContent');
+          assert.equal(element._computeBaseDropdownContent.callCount, 0);
+          commentApiWrapper.loadComments().then().then(() => {
+            assert.equal(element._computeBaseDropdownContent.callCount, 1);
+            done();
+          });
+        });
 
     test('_computePatchDropdownContent called when basePatchNum updates', () => {
       element.revisions = [
@@ -222,7 +236,7 @@
       assert.equal(element._computePatchDropdownContent.callCount, 1);
     });
 
-    test('_computePatchDropdownContent called when comments update', () => {
+    test('_computePatchDropdownContent called when comments update', done => {
       element.revisions = [
         {commit: {}},
         {commit: {}},
@@ -242,17 +256,12 @@
       // Should be recomputed for each available patch
       sandbox.stub(element, '_computePatchDropdownContent');
       assert.equal(element._computePatchDropdownContent.callCount, 0);
-      element.set('comments', {
-        file: [{
-          message: 'test',
-          patch_set: 2,
-        }],
+      commentApiWrapper.loadComments().then().then(() => {
+        done();
       });
-      assert.equal(element._computePatchDropdownContent.callCount, 1);
     });
 
     test('_computePatchDropdownContent', () => {
-      const comments = {};
       const availablePatches = [
         {num: 'edit'},
         {num: 3},
@@ -303,7 +312,8 @@
       ];
 
       assert.deepEqual(element._computePatchDropdownContent(availablePatches,
-          basePatchNum, sortedRevisions, comments), expectedResult);
+          basePatchNum, sortedRevisions, element.changeComments),
+          expectedResult);
     });
 
     test('filesWeblinks', () => {
@@ -331,7 +341,7 @@
 
     test('_computePatchSetCommentsString', () => {
       // Test string with unresolved comments.
-      comments = {
+      element.changeComments._comments = {
         foo: [{
           id: '27dcee4d_f7b77cfa',
           message: 'test',
@@ -351,17 +361,18 @@
         abc: [],
       };
 
-      assert.equal(element._computePatchSetCommentsString(comments, 1),
-          ' (3 comments, 1 unresolved)');
+      assert.equal(element._computePatchSetCommentsString(
+          element.changeComments, 1), ' (3 comments, 1 unresolved)');
 
       // Test string with no unresolved comments.
-      delete comments['foo'];
-      assert.equal(element._computePatchSetCommentsString(comments, 1),
-          ' (2 comments)');
+      delete element.changeComments._comments['foo'];
+      assert.equal(element._computePatchSetCommentsString(
+          element.changeComments, 1), ' (2 comments)');
 
       // Test string with no comments.
-      delete comments['bar'];
-      assert.equal(element._computePatchSetCommentsString(comments, 1), '');
+      delete element.changeComments._comments['bar'];
+      assert.equal(element._computePatchSetCommentsString(
+          element.changeComments, 1), '');
     });
 
     test('patch-range-change fires', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 47db5f0..3993b86 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -17,34 +17,24 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-tooltip/gr-tooltip.html">
 
 <dom-module id="gr-selection-action-box">
   <template>
     <style include="shared-styles">
       :host {
-        --gr-arrow-size: .65em;
-
-        background-color: rgba(22, 22, 22, .9);
-        border-radius: 3px;
-        color: #fff;
         cursor: pointer;
         font-family: var(--font-family);
-        padding: .5em .75em;
         position: absolute;
         white-space: nowrap;
       }
-      .arrow {
-        border: var(--gr-arrow-size) solid transparent;
-        border-top: var(--gr-arrow-size) solid rgba(22, 22, 22, 0.9);
-        height: 0;
-        left: calc(50% - var(--gr-arrow-size));
-        margin-top: .5em;
-        position: absolute;
-        width: 0;
+      #tooltip {
+        --tooltip-background-color: rgba(22, 22, 22, .9);
       }
     </style>
-    Press <strong>c</strong> to comment.
-    <div class="arrow"></div>
+    <gr-tooltip id="tooltip"
+        text="press c to comment"
+        position-below="[[positionBelow]]"></gr-tooltip>
   </template>
   <script src="gr-selection-action-box.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index c228235..61a6eb2 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -37,6 +37,7 @@
           endChar: NaN,
         },
       },
+      positionBelow: Boolean,
       side: {
         type: String,
         value: '',
@@ -58,7 +59,7 @@
     placeAbove(el) {
       Polymer.dom.flush();
       const rect = this._getTargetBoundingRect(el);
-      const boxRect = this.getBoundingClientRect();
+      const boxRect = this.$.tooltip.getBoundingClientRect();
       const parentRect = this.parentElement.getBoundingClientRect();
       this.style.top =
           rect.top - parentRect.top - boxRect.height - 6 + 'px';
@@ -66,6 +67,17 @@
           rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
     },
 
+    placeBelow(el) {
+      Polymer.dom.flush();
+      const rect = this._getTargetBoundingRect(el);
+      const boxRect = this.$.tooltip.getBoundingClientRect();
+      const parentRect = this.parentElement.getBoundingClientRect();
+      this.style.top =
+          rect.top - parentRect.top + boxRect.height - 6 + 'px';
+      this.style.left =
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
+    },
+
     _getTargetBoundingRect(el) {
       let rect;
       if (el instanceof Text) {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index 8c70772..7fc634c 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -38,15 +38,17 @@
   suite('gr-selection-action-box', () => {
     let container;
     let element;
+    let sandbox;
 
     setup(() => {
       container = fixture('basic');
       element = container.querySelector('gr-selection-action-box');
-      sinon.stub(element, 'fire');
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(element, 'fire');
     });
 
     teardown(() => {
-      element.fire.restore();
+      sandbox.restore();
     });
 
     test('ignores regular keys', () => {
@@ -65,10 +67,10 @@
       setup(() => {
         e = {
           button: 0,
-          preventDefault: sinon.stub(),
-          stopPropagation: sinon.stub(),
+          preventDefault: sandbox.stub(),
+          stopPropagation: sandbox.stub(),
         };
-        sinon.stub(element, '_fireCreateComment');
+        sandbox.stub(element, '_fireCreateComment');
       });
 
       test('event handled if main button', () => {
@@ -107,20 +109,14 @@
 
       setup(() => {
         target = container.querySelector('.target');
-        sinon.stub(container, 'getBoundingClientRect').returns(
+        sandbox.stub(container, 'getBoundingClientRect').returns(
             {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
-        sinon.stub(element, '_getTargetBoundingRect').returns(
+        sandbox.stub(element, '_getTargetBoundingRect').returns(
             {top: 42, bottom: 20, left: 30, right: 40, width: 100, height: 60});
-        sinon.stub(element, 'getBoundingClientRect').returns(
+        sandbox.stub(element.$.tooltip, 'getBoundingClientRect').returns(
             {width: 10, height: 10});
       });
 
-      teardown(() => {
-        element.getBoundingClientRect.restore();
-        container.getBoundingClientRect.restore();
-        element._getTargetBoundingRect.restore();
-      });
-
       test('placeAbove for Element argument', () => {
         element.placeAbove(target);
         assert.equal(element.style.top, '25px');
@@ -133,13 +129,24 @@
         assert.equal(element.style.left, '72px');
       });
 
+      test('placeBelow for Element argument', () => {
+        element.placeBelow(target);
+        assert.equal(element.style.top, '45px');
+        assert.equal(element.style.left, '72px');
+      });
+
+      test('placeBelow for Text Node argument', () => {
+        element.placeBelow(target.firstChild);
+        assert.equal(element.style.top, '45px');
+        assert.equal(element.style.left, '72px');
+      });
+
       test('uses document.createRange', () => {
-        sinon.spy(document, 'createRange');
+        sandbox.spy(document, 'createRange');
         element._getTargetBoundingRect.restore();
-        sinon.spy(element, '_getTargetBoundingRect');
+        sandbox.spy(element, '_getTargetBoundingRect');
         element.placeAbove(target.firstChild);
         assert.isTrue(document.createRange.called);
-        document.createRange.restore();
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
index 9eb4974..c769d25 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -85,8 +85,8 @@
           confirm-label="Edit"
           on-confirm="_handleEditConfirm"
           on-cancel="_handleDialogCancel">
-        <div class="header">Edit a file</div>
-        <div class="main">
+        <div class="header" slot="header">Edit a file</div>
+        <div class="main" slot="main">
           <gr-autocomplete
               placeholder="Enter an existing or new full file path."
               query="[[_query]]"
@@ -100,8 +100,8 @@
           confirm-label="Delete"
           on-confirm="_handleDeleteConfirm"
           on-cancel="_handleDialogCancel">
-        <div class="header">Delete a file</div>
-        <div class="main">
+        <div class="header" slot="header">Delete a file</div>
+        <div class="main" slot="main">
           <gr-autocomplete
               placeholder="Enter an existing full file path."
               query="[[_query]]"
@@ -115,8 +115,8 @@
           confirm-label="Rename"
           on-confirm="_handleRenameConfirm"
           on-cancel="_handleDialogCancel">
-        <div class="header">Rename a file</div>
-        <div class="main">
+        <div class="header" slot="header">Rename a file</div>
+        <div class="main" slot="main">
           <gr-autocomplete
               placeholder="Enter an existing full file path."
               query="[[_query]]"
@@ -134,8 +134,8 @@
           confirm-label="Restore"
           on-confirm="_handleRestoreConfirm"
           on-cancel="_handleDialogCancel">
-        <div class="header">Restore this file?</div>
-        <div class="main">
+        <div class="header" slot="header">Restore this file?</div>
+        <div class="main" slot="main">
           <input
               is="iron-input"
               disabled
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index a2a1c0b..461f6c9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  const INIT_PROPERTIES_TIMEOUT_MS = 10000;
+
   Polymer({
     is: 'gr-endpoint-decorator',
 
@@ -40,59 +42,73 @@
 
     _initDecoration(name, plugin) {
       const el = document.createElement(name);
-      this._initProperties(el, plugin, this.getContentChildren().find(
-          el => el.nodeName !== 'GR-ENDPOINT-PARAM'));
-      this._appendChild(el);
-      return el;
+      return this._initProperties(el, plugin,
+          this.getContentChildren().find(
+              el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
+          .then(el => this._appendChild(el));
     },
 
     _initReplacement(name, plugin) {
-      this.getContentChildNodes().forEach(node => node.remove());
+      this.getContentChildNodes()
+          .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
+          .forEach(node => node.remove());
       const el = document.createElement(name);
-      this._initProperties(el, plugin);
-      this._appendChild(el);
-      return el;
+      return this._initProperties(el, plugin).then(
+          el => this._appendChild(el));
     },
 
     _getEndpointParams() {
-      return Polymer.dom(this).querySelectorAll('gr-endpoint-param').map(el => {
-        return {name: el.getAttribute('name'), value: el.value};
-      });
+      return Polymer.dom(this).querySelectorAll('gr-endpoint-param');
     },
 
     /**
      * @param {!Element} el
      * @param {!Object} plugin
      * @param {!Element=} opt_content
+     * @return {!Promise<Element>}
      */
     _initProperties(el, plugin, opt_content) {
       el.plugin = plugin;
       if (opt_content) {
         el.content = opt_content;
       }
-      for (const {name, value} of this._getEndpointParams()) {
-        el[name] = value;
-      }
+      const expectProperties = this._getEndpointParams().map(
+          paramEl => plugin.attributeHelper(paramEl).get('value')
+              .then(value => el[paramEl.getAttribute('name')] = value)
+      );
+      const timeout = new Promise(
+        resolve => setTimeout(() => {
+          console.warn(
+              'Timeout waiting for endpoint properties initialization.' +
+              `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
+          resolve();
+        }, INIT_PROPERTIES_TIMEOUT_MS));
+      return Promise.race([timeout, Promise.all(expectProperties)])
+          .then(() => el);
     },
 
     _appendChild(el) {
-      Polymer.dom(this.root).appendChild(el);
+      return Polymer.dom(this.root).appendChild(el);
     },
 
     _initModule({moduleName, plugin, type, domHook}) {
-      let el;
+      let initPromise;
       switch (type) {
         case 'decorate':
-          el = this._initDecoration(moduleName, plugin);
+          initPromise = this._initDecoration(moduleName, plugin);
           break;
         case 'replace':
-          el = this._initReplacement(moduleName, plugin);
+          initPromise = this._initReplacement(moduleName, plugin);
           break;
       }
-      if (el) {
-        domHook.handleInstanceAttached(el);
+      if (!initPromise) {
+        console.warn('Unable to initialize module' +
+            `${moduleName} from ${plugin.getPluginName()}`);
       }
-      this._domHooks.set(el, domHook);
+      initPromise.then(el => {
+        domHook.handleInstanceAttached(el);
+        this._domHooks.set(el, domHook);
+      });
     },
 
     ready() {
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index c7ab3d9..cfebc95 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -28,48 +28,45 @@
 
 <test-fixture id="basic">
   <template>
-    <gr-endpoint-decorator name="foo">
-      <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
-    </gr-endpoint-decorator>
+    <div>
+      <gr-endpoint-decorator name="first">
+        <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      <gr-endpoint-decorator name="second">
+        <gr-endpoint-param name="someparam" value="foofoo"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+      <gr-endpoint-decorator name="banana">
+        <gr-endpoint-param name="someParam" value="yes"></gr-endpoint-param>
+      </gr-endpoint-decorator>
+    </div>
   </template>
 </test-fixture>
 
 <script>
   suite('gr-endpoint-decorator', () => {
+    let container;
     let sandbox;
-    let element;
     let plugin;
-    let domHookStub;
+    let decorationHook;
+    let replacementHook;
 
     setup(done => {
-      Gerrit._endpoints = new GrPluginEndpoints();
-
       sandbox = sinon.sandbox.create();
-
-      domHookStub = {
-        handleInstanceAttached: sandbox.stub(),
-        handleInstanceDetached: sandbox.stub(),
-        getPublicAPI: () => domHookStub,
-      };
-      sandbox.stub(
-          GrDomHooksManager.prototype, 'getDomHook').returns(domHookStub);
-
-      // NB: Order is important.
-      Gerrit.install(p => {
-        plugin = p;
-        plugin.registerCustomComponent('foo', 'some-module');
-        plugin.registerCustomComponent('foo', 'other-module', {replace: true});
-        plugin.registerCustomComponent('bar', 'some-module');
-      }, '0.1', 'http://some/plugin/url.html');
-
-      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
-
-      element = fixture('basic');
-      sandbox.stub(element, '_initDecoration').returns({});
-      sandbox.stub(element, '_initReplacement').returns({});
-      sandbox.stub(element, 'importHref', (url, resolve) => resolve());
-
+      stub('gr-endpoint-decorator', {
+        _import: sandbox.stub().returns(Promise.resolve()),
+      });
+      // Since _endpoints are global, must reset state.
+      Gerrit._endpoints = new GrPluginEndpoints();
+      container = fixture('basic');
+      Gerrit.install(p => plugin = p, '0.1', 'http://some/plugin/url.html');
+      hooks = [];
+      // Decoration
+      decorationHook = plugin.registerCustomComponent('first', 'some-module');
+      // Replacement
+      replacementHook = plugin.registerCustomComponent(
+          'second', 'other-module', {replace: true});
+      // Mimic all plugins loaded.
+      Gerrit._setPluginsCount(0);
       flush(done);
     });
 
@@ -77,51 +74,79 @@
       sandbox.restore();
     });
 
-    test('imports plugin-provided module', () => {
-      assert.isTrue(
-          element.importHref.calledWith(new URL('http://some/plugin/url.html')));
+    test('imports plugin-provided modules into endpoints', () => {
+      const endpoints =
+          Array.from(container.querySelectorAll('gr-endpoint-decorator'));
+      assert.equal(endpoints.length, 3);
+      endpoints.forEach(element => {
+        assert.isTrue(
+            element._import.calledWith(new URL('http://some/plugin/url.html')));
+      });
     });
 
-    test('inits decoration dom hook', () => {
-      assert.strictEqual(
-          element._initDecoration.lastCall.args[0], 'some-module');
-      assert.strictEqual(
-          element._initDecoration.lastCall.args[1], plugin);
+    test('decoration', () => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="first"]');
+      const module = Polymer.dom(element.root).children.find(
+          element => element.nodeName === 'SOME-MODULE');
+      assert.isOk(module);
+      assert.equal(module['someparam'], 'barbar');
+      return decorationHook.getLastAttached().then(element => {
+        assert.strictEqual(element, module);
+      }).then(() => {
+        element.remove();
+        assert.equal(decorationHook.getAllAttached().length, 0);
+      });
     });
 
-    test('inits replacement dom hook', () => {
-      assert.strictEqual(
-          element._initReplacement.lastCall.args[0], 'other-module');
-      assert.strictEqual(
-          element._initReplacement.lastCall.args[1], plugin);
+    test('replacement', () => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="second"]');
+      const module = Polymer.dom(element.root).children.find(
+          element => element.nodeName === 'OTHER-MODULE');
+      assert.isOk(module);
+      assert.equal(module['someparam'], 'foofoo');
+      return replacementHook.getLastAttached().then(element => {
+        assert.strictEqual(element, module);
+      }).then(() => {
+        element.remove();
+        assert.equal(replacementHook.getAllAttached().length, 0);
+      });
     });
 
-    test('calls dom hook handleInstanceAttached', () => {
-      assert.equal(domHookStub.handleInstanceAttached.callCount, 2);
-    });
-
-    test('calls dom hook handleInstanceDetached', () => {
-      element.detached();
-      assert.equal(domHookStub.handleInstanceDetached.callCount, 2);
-    });
-
-    test('installs modules on late registration', done => {
-      domHookStub.handleInstanceAttached.reset();
-      plugin.registerCustomComponent('foo', 'noob-noob');
+    test('late registration', done => {
+      plugin.registerCustomComponent('banana', 'noob-noob');
       flush(() => {
-        assert.equal(domHookStub.handleInstanceAttached.callCount, 1);
-        assert.strictEqual(
-            element._initDecoration.lastCall.args[0], 'noob-noob');
-        assert.strictEqual(
-            element._initDecoration.lastCall.args[1], plugin);
+        const element =
+            container.querySelector('gr-endpoint-decorator[name="banana"]');
+        const module = Polymer.dom(element.root).children.find(
+            element => element.nodeName === 'NOOB-NOOB');
+        assert.isOk(module);
         done();
       });
     });
 
-    test('params', () => {
-      const instance = document.createElement('foo');
-      element._initProperties(instance, plugin);
-      assert.equal(instance.someparam, 'barbar');
+    test('late param setup', done => {
+      const element =
+          container.querySelector('gr-endpoint-decorator[name="banana"]');
+      const param = Polymer.dom(element).querySelector('gr-endpoint-param');
+      param['value'] = undefined;
+      plugin.registerCustomComponent('banana', 'noob-noob');
+      flush(() => {
+        let module = Polymer.dom(element.root).children.find(
+            element => element.nodeName === 'NOOB-NOOB');
+        // Module waits for param to be defined.
+        assert.isNotOk(module);
+        const value = {abc: 'def'};
+        param.value = value;
+        flush(() => {
+          module = Polymer.dom(element.root).children.find(
+              element => element.nodeName === 'NOOB-NOOB');
+          assert.isOk(module);
+          assert.strictEqual(module['someParam'], value);
+          done();
+        });
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
index 5a2ab59..2833654 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
@@ -18,7 +18,10 @@
     is: 'gr-endpoint-param',
     properties: {
       name: String,
-      value: Object,
+      value: {
+        type: Object,
+        notify: true,
+      },
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 494b9e8..55164e0 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -44,17 +44,28 @@
               date-str="[[_account.registered_on]]"></gr-date-formatter>
         </span>
       </section>
-      <section>
+      <section id="usernameSection">
         <span class="title">Username</span>
-        <span class="value">[[_account.username]]</span>
+        <span
+            hidden$="[[usernameMutable]]"
+            class="value">[[_username]]</span>
+        <span
+            hidden$="[[!usernameMutable]]"
+            class="value">
+          <input
+              is="iron-input"
+              id="usernameInput"
+              disabled="[[_saving]]"
+              on-keydown="_handleKeydown"
+              bind-value="{{_username}}">
       </section>
       <section id="nameSection">
         <span class="title">Full name</span>
         <span
-            hidden$="[[mutable]]"
+            hidden$="[[nameMutable]]"
             class="value">[[_account.name]]</span>
         <span
-            hidden$="[[!mutable]]"
+            hidden$="[[!nameMutable]]"
             class="value">
           <input
               is="iron-input"
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 5d7f8a6..a698c71 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -24,21 +24,31 @@
      */
 
     properties: {
-      mutable: {
+      usernameMutable: {
         type: Boolean,
         notify: true,
-        computed: '_computeMutable(_serverConfig)',
+        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+      },
+      nameMutable: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeNameMutable(_serverConfig)',
       },
       hasUnsavedChanges: {
         type: Boolean,
         notify: true,
-        computed: '_computeHasUnsavedChanges(_hasNameChange, _hasStatusChange)',
+        computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
+            '_hasUsernameChange, _hasStatusChange)',
       },
 
       _hasNameChange: {
         type: Boolean,
         value: false,
       },
+      _hasUsernameChange: {
+        type: Boolean,
+        value: false,
+      },
       _hasStatusChange: {
         type: Boolean,
         value: false,
@@ -54,6 +64,10 @@
       /** @type {?} */
       _account: Object,
       _serverConfig: Object,
+      _username: {
+        type: String,
+        observer: '_usernameChanged',
+      },
     },
 
     observers: [
@@ -71,7 +85,11 @@
       }));
 
       promises.push(this.$.restAPI.getAccount().then(account => {
+        // Provide predefined value for username to trigger computation of
+        // username mutability.
+        account.username = account.username || '';
         this._account = account;
+        this._username = account.username;
       }));
 
       return Promise.all(promises).then(() => {
@@ -88,6 +106,7 @@
       // Set only the fields that have changed.
       // Must be done in sequence to avoid race conditions (@see Issue 5721)
       return this._maybeSetName()
+          .then(this._maybeSetUsername.bind(this))
           .then(this._maybeSetStatus.bind(this))
           .then(() => {
             this._hasNameChange = false;
@@ -98,9 +117,15 @@
     },
 
     _maybeSetName() {
-      return this._hasNameChange && this.mutable ?
-                this.$.restAPI.setAccountName(this._account.name) :
-                Promise.resolve();
+      return this._hasNameChange && this.nameMutable ?
+          this.$.restAPI.setAccountName(this._account.name) :
+          Promise.resolve();
+    },
+
+    _maybeSetUsername() {
+      return this._hasUsernameChange && this.usernameMutable ?
+          this.$.restAPI.setAccountUsername(this._username) :
+          Promise.resolve();
     },
 
     _maybeSetStatus() {
@@ -109,11 +134,17 @@
           Promise.resolve();
     },
 
-    _computeHasUnsavedChanges(name, status) {
-      return name || status;
+    _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
+      return nameChanged || usernameChanged || statusChanged;
     },
 
-    _computeMutable(config) {
+    _computeUsernameMutable(config, username) {
+      // Username may not be changed once it is set.
+      return config.auth.editable_account_fields.includes('USER_NAME') &&
+          !username;
+    },
+
+    _computeNameMutable(config) {
       return config.auth.editable_account_fields.includes('FULL_NAME');
     },
 
@@ -122,6 +153,11 @@
       this._hasStatusChange = true;
     },
 
+    _usernameChanged() {
+      if (this._loading) { return; }
+      this._hasUsernameChange = true;
+    },
+
     _nameChanged() {
       if (this._loading) { return; }
       this._hasNameChange = true;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index 84fbc09..d27d153 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -84,18 +84,18 @@
       assert.equal(valueOf('Username').textContent, account.username);
     });
 
-    test('user name render (immutable)', () => {
+    test('full name render (immutable)', () => {
       const section = element.$.nameSection;
       const displaySpan = section.querySelectorAll('.value')[0];
       const inputSpan = section.querySelectorAll('.value')[1];
 
-      assert.isFalse(element.mutable);
+      assert.isFalse(element.nameMutable);
       assert.isFalse(displaySpan.hasAttribute('hidden'));
       assert.equal(displaySpan.textContent, account.name);
       assert.isTrue(inputSpan.hasAttribute('hidden'));
     });
 
-    test('user name render (mutable)', () => {
+    test('full name render (mutable)', () => {
       element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME']}});
 
@@ -103,32 +103,64 @@
       const displaySpan = section.querySelectorAll('.value')[0];
       const inputSpan = section.querySelectorAll('.value')[1];
 
-      assert.isTrue(element.mutable);
+      assert.isTrue(element.nameMutable);
       assert.isTrue(displaySpan.hasAttribute('hidden'));
       assert.equal(element.$.nameInput.bindValue, account.name);
       assert.isFalse(inputSpan.hasAttribute('hidden'));
     });
 
+    test('username render (immutable)', () => {
+      const section = element.$.usernameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isFalse(element.usernameMutable);
+      assert.isFalse(displaySpan.hasAttribute('hidden'));
+      assert.equal(displaySpan.textContent, account.username);
+      assert.isTrue(inputSpan.hasAttribute('hidden'));
+    });
+
+    test('username render (mutable)', () => {
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['USER_NAME']}});
+      element.set('_account.username', '');
+      element.set('_username', '');
+
+      const section = element.$.usernameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isTrue(element.usernameMutable);
+      assert.isTrue(displaySpan.hasAttribute('hidden'));
+      assert.equal(element.$.usernameInput.bindValue, account.username);
+      assert.isFalse(inputSpan.hasAttribute('hidden'));
+    });
+
     suite('account info edit', () => {
       let nameChangedSpy;
+      let usernameChangedSpy;
       let statusChangedSpy;
       let nameStub;
+      let usernameStub;
       let statusStub;
 
       setup(() => {
         nameChangedSpy = sandbox.spy(element, '_nameChanged');
+        usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
 
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName', name =>
-          Promise.resolve());
+        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+            name => Promise.resolve());
+        usernameStub = sandbox.stub(element.$.restAPI, 'setAccountUsername',
+            username => Promise.resolve());
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
             status => Promise.resolve());
       });
 
       test('name', done => {
-        assert.isTrue(element.mutable);
+        assert.isTrue(element.nameMutable);
         assert.isFalse(element.hasUnsavedChanges);
 
         element.set('_account.name', 'new name');
@@ -137,18 +169,40 @@
         assert.isFalse(statusChangedSpy.called);
         assert.isTrue(element.hasUnsavedChanges);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.nameInput, 13);
+        element.save().then(() => {
+          assert.isFalse(usernameStub.called);
+          assert.isTrue(nameStub.called);
+          assert.isFalse(statusStub.called);
+          nameStub.lastCall.returnValue.then(() => {
+            assert.equal(nameStub.lastCall.args[0], 'new name');
+            done();
+          });
+        });
+      });
 
-        assert.isTrue(nameStub.called);
-        assert.isFalse(statusStub.called);
-        nameStub.lastCall.returnValue.then(() => {
-          assert.equal(nameStub.lastCall.args[0], 'new name');
-          done();
+      test('username', done => {
+        element.set('_account.username', '');
+        element._hasUsernameChange = false;
+        assert.isTrue(element.usernameMutable);
+
+        element.set('_username', 'new username');
+
+        assert.isTrue(usernameChangedSpy.called);
+        assert.isFalse(statusChangedSpy.called);
+        assert.isTrue(element.hasUnsavedChanges);
+
+        element.save().then(() => {
+          assert.isTrue(usernameStub.called);
+          assert.isFalse(nameStub.called);
+          assert.isFalse(statusStub.called);
+          usernameStub.lastCall.returnValue.then(() => {
+            assert.equal(usernameStub.lastCall.args[0], 'new username');
+            done();
+          });
         });
       });
 
       test('status', done => {
-        assert.isTrue(element.mutable);
         assert.isFalse(element.hasUnsavedChanges);
 
         element.set('_account.status', 'new status');
@@ -158,6 +212,7 @@
         assert.isTrue(element.hasUnsavedChanges);
 
         element.save().then(() => {
+          assert.isFalse(usernameStub.called);
           assert.isTrue(statusStub.called);
           assert.isFalse(nameStub.called);
           statusStub.lastCall.returnValue.then(() => {
@@ -178,16 +233,18 @@
         nameChangedSpy = sandbox.spy(element, '_nameChanged');
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+            {auth: {editable_account_fields: ['FULL_NAME']}});
 
-        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName', name =>
-          Promise.resolve());
+        nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
+            name => Promise.resolve());
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
             status => Promise.resolve());
+        sandbox.stub(element.$.restAPI, 'setAccountUsername',
+            username => Promise.resolve());
       });
 
       test('set name and status', done => {
-        assert.isTrue(element.mutable);
+        assert.isTrue(element.nameMutable);
         assert.isFalse(element.hasUnsavedChanges);
 
         element.set('_account.name', 'new name');
@@ -231,7 +288,7 @@
         const displaySpan = section.querySelectorAll('.value')[0];
         const inputSpan = section.querySelectorAll('.value')[1];
 
-        assert.isFalse(element.mutable);
+        assert.isFalse(element.nameMutable);
 
         assert.isFalse(element.hasUnsavedChanges);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index 543c86d..26a2470 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -24,7 +24,7 @@
     },
 
     _handleMoveUpButton(e) {
-      const index = e.target.dataIndex;
+      const index = Polymer.dom(e).localTarget.dataIndex;
       if (index === 0) { return; }
       const row = this.menuItems[index];
       const prev = this.menuItems[index - 1];
@@ -32,7 +32,7 @@
     },
 
     _handleMoveDownButton(e) {
-      const index = e.target.dataIndex;
+      const index = Polymer.dom(e).localTarget.dataIndex;
       if (index === this.menuItems.length - 1) { return; }
       const row = this.menuItems[index];
       const next = this.menuItems[index + 1];
@@ -40,7 +40,7 @@
     },
 
     _handleDeleteButton(e) {
-      const index = e.target.dataIndex;
+      const index = Polymer.dom(e).localTarget.dataIndex;
       this.splice('menuItems', index, 1);
     },
 
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index f16ba6c..c70ae88 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -47,9 +47,10 @@
     // Click the up/down button (according to direction) for the index'th row.
     // The index of the first row is 0, corresponding to the array.
     function move(element, index, direction) {
-      const selector =
-          'tr:nth-child(' + (index + 1) + ') .move' + direction + 'Button';
-      const button = element.$$('tbody').querySelector(selector);
+      const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
+          direction + 'Button';
+      const button =
+          element.$$('tbody').querySelector(selector).$$('paper-button');
       MockInteractions.tap(button);
     }
 
@@ -141,15 +142,15 @@
           ['first name', 'second name', 'third name']);
 
       // Tap the delete button for the middle item.
-      MockInteractions.tap(
-          element.$$('tbody').querySelector('tr:nth-child(2) .remove-button'));
+      MockInteractions.tap(element.$$('tbody')
+          .querySelector('tr:nth-child(2) .remove-button').$$('paper-button'));
 
       assertMenuNamesEqual(element, ['first name', 'third name']);
 
       // Delete remaining items.
       for (let i = 0; i < 2; i++) {
-        MockInteractions.tap(
-            element.$$('tbody').querySelector('tr:first-child .remove-button'));
+        MockInteractions.tap(element.$$('tbody')
+            .querySelector('tr:first-child .remove-button').$$('paper-button'));
       }
       assertMenuNamesEqual(element, []);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index de6783d..0cbd1f6 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -16,6 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
@@ -37,18 +38,23 @@
       header {
         border-bottom: 1px solid #cdcdcd;
         font-family: var(--font-family-bold);
+        margin-bottom: 1em;
       }
-      header,
-      main,
-      footer {
+      .container {
         padding: .5em 1.5em;
       }
       footer {
         display: flex;
-        justify-content: space-between;
+        justify-content: flex-end;
+      }
+      footer gr-button {
+        margin-left: 1em;
+      }
+      input {
+        width: 20em;
       }
     </style>
-    <main class="gr-form-styles">
+    <div class="container gr-form-styles">
       <header>Please confirm your contact information</header>
       <main>
         <p>
@@ -64,8 +70,15 @@
               is="iron-input"
               id="name"
               bind-value="{{_account.name}}"
-              disabled="[[_saving]]"
-              on-keydown="_handleNameKeydown">
+              disabled="[[_saving]]">
+        </section>
+        <section>
+          <div class="title">Username</div>
+          <input
+              is="iron-input"
+              id="username"
+              bind-value="{{_account.username}}"
+              disabled="[[_saving]]">
         </section>
         <section>
           <div class="title">Preferred Email</div>
@@ -78,19 +91,26 @@
             </template>
           </select>
         </section>
+        <hr>
+        <p>
+          More configuration options for Gerrit may be found in the
+          <a on-tap="close" href$="[[_computeSettingsUrl(_account)]]">settings</a>.
+        </p>
       </main>
       <footer>
         <gr-button
-            id="saveButton"
-            primary
-            disabled="[[_saving]]"
-            on-tap="_handleSave">Save</gr-button>
-        <gr-button
             id="closeButton"
+            link
             disabled="[[_saving]]"
             on-tap="_handleClose">Close</gr-button>
+        <gr-button
+            id="saveButton"
+            primary
+            link
+            disabled="[[_computeSaveDisabled(_account.name, _account.username, _account.email, _saving)]]"
+            on-tap="_handleSave">Save</gr-button>
       </footer>
-    </main>
+    </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-registration-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 71d80eb..406d16c 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -31,8 +31,18 @@
 
     properties: {
       /** @type {?} */
-      _account: Object,
-      _saving: Boolean,
+      _account: {
+        type: Object,
+        value: () => {
+          // Prepopulate possibly undefined fields with values to trigger
+          // computed bindings.
+          return {email: null, name: null, username: null};
+        },
+      },
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     hostAttributes: {
@@ -41,22 +51,19 @@
 
     attached() {
       this.$.restAPI.getAccount().then(account => {
-        this._account = account;
+        // Using Object.assign here allows preservation of the default values
+        // supplied in the value generating function of this._account, unless
+        // they are overridden by properties in the account from the response.
+        this._account = Object.assign({}, this._account, account);
       });
     },
 
-    _handleNameKeydown(e) {
-      if (e.keyCode === 13) { // Enter
-        e.stopPropagation();
-        this._save();
-      }
-    },
-
     _save() {
       this._saving = true;
       const promises = [
         this.$.restAPI.setAccountName(this.$.name.value),
-        this.$.restAPI.setPreferredAccountEmail(this.$.email.value),
+        this.$.restAPI.setAccountUsername(this.$.username.value),
+        this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
       ];
       return Promise.all(promises).then(() => {
         this._saving = false;
@@ -66,15 +73,25 @@
 
     _handleSave(e) {
       e.preventDefault();
-      this._save().then(() => {
-        this.fire('close');
-      });
+      this._save().then(this.close.bind(this));
     },
 
     _handleClose(e) {
       e.preventDefault();
+      this.close();
+    },
+
+    close() {
       this._saving = true; // disable buttons indefinitely
       this.fire('close');
     },
+
+    _computeSaveDisabled(name, username, email, saving) {
+      return !name || !username || !email || saving;
+    },
+
+    _computeSettingsUrl() {
+      return Gerrit.Nav.getUrlForSettings();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
index 858c3ae..15b4fa2 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -41,13 +41,16 @@
   suite('gr-registration-dialog tests', () => {
     let element;
     let account;
+    let sandbox;
     let _listeners;
 
     setup(done => {
+      sandbox = sinon.sandbox.create();
       _listeners = {};
 
       account = {
         name: 'name',
+        username: 'username',
         email: 'email',
         secondary_emails: [
           'email2',
@@ -65,6 +68,10 @@
           account.name = name;
           return Promise.resolve();
         },
+        setAccountUsername(username) {
+          account.username = username;
+          return Promise.resolve();
+        },
         setPreferredAccountEmail(email) {
           account.email = email;
           return Promise.resolve();
@@ -75,6 +82,7 @@
     });
 
     teardown(() => {
+      sandbox.restore();
       for (const eventType in _listeners) {
         if (_listeners.hasOwnProperty(eventType)) {
           element.removeEventListener(eventType, _listeners[eventType]);
@@ -119,32 +127,26 @@
       }).then(done);
     });
 
-    test('saves name and preferred email', done => {
+    test('saves account details', done => {
       flush(() => {
         element.$.name.value = 'new name';
+        element.$.username.value = 'new username';
         element.$.email.value = 'email3';
 
         // Nothing should be committed yet.
         assert.equal(account.name, 'name');
+        assert.equal(account.username, 'username');
         assert.equal(account.email, 'email');
 
         // Save and verify new values are committed.
         save().then(() => {
           assert.equal(account.name, 'new name');
+          assert.equal(account.username, 'new username');
           assert.equal(account.email, 'email3');
         }).then(done);
       });
     });
 
-    test('pressing enter saves name', done => {
-      element.$.name.value = 'entered name';
-      save(() => {
-        MockInteractions.pressAndReleaseKeyOn(element.$.name, 13); // 'enter'
-      }).then(() => {
-        assert.equal(account.name, 'entered name');
-      }).then(done);
-    });
-
     test('email select properly populated', done => {
       element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
       flush(() => {
@@ -152,5 +154,15 @@
         done();
       });
     });
+
+    test('save btn disabled', () => {
+      const compute = element._computeSaveDisabled;
+      assert.isTrue(compute('', '', '', false));
+      assert.isTrue(compute('', 'test', 'test', false));
+      assert.isTrue(compute('test', '', 'test', false));
+      assert.isTrue(compute('test', 'test', '', false));
+      assert.isTrue(compute('test', 'test', 'test', true));
+      assert.isFalse(compute('test', 'test', 'test', false));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 51fa616..cc35e08 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -56,6 +56,9 @@
         font-family: var(--font-family-bold);
         margin-left: 1em;
         text-decoration: none;
+        --gr-button: {
+          padding: 0;
+        }
       }
     </style>
     <span class="text">[[text]]</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index 6918f30..a56394e 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -75,7 +75,7 @@
       /* styles for raised buttons specifically*/
       :host([primary]) paper-button[raised],
       :host([secondary]) paper-button[raised] {
-        background-color: var(--gr-button-background, --color-link);
+        background-color: var(--color-link);
         color: #fff;
       }
       :host([primary]) paper-button[raised]:hover,
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index efa2b35..6801dea 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -55,10 +55,10 @@
       }
     </style>
     <div class="container">
-      <header><content select=".header"></content></header>
-      <main><content select=".main"></content></main>
+      <header><slot name="header"></slot></header>
+      <main><slot name="main"></slot></main>
       <footer>
-        <gr-button link on-tap="_handleCancelTap">Cancel</gr-button>
+        <gr-button link on-tap="_handleCancelTap">[[cancelLabel]]</gr-button>
         <gr-button link primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
index 3d5e781..f322e25 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
@@ -34,6 +34,10 @@
         type: String,
         value: 'Confirm',
       },
+      cancelLabel: {
+        type: String,
+        value: 'Cancel',
+      },
       disabled: {
         type: Boolean,
         value: false,
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html
new file mode 100644
index 0000000..832747e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.html
@@ -0,0 +1,57 @@
+<!--
+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.
+-->
+<script>
+  (function(window) {
+    'use strict';
+    const GrCountStringFormatter = window.GrCountStringFormatter || {};
+
+    /**
+     * Returns a count plus string that is pluralized when necessary.
+     *
+     * @param {number} count
+     * @param {string} noun
+     * @return {string}
+     */
+    GrCountStringFormatter.computePluralString = function(count, noun) {
+      return this.computeString(count, noun) + (count > 1 ? 's' : '');
+    };
+
+    /**
+     * Returns a count plus string that is not pluralized.
+     *
+     * @param {number} count
+     * @param {string} noun
+     * @return {string}
+     */
+    GrCountStringFormatter.computeString = function(count, noun) {
+      if (count === 0) { return ''; }
+      return count + ' ' + noun;
+    };
+
+    /**
+     * Returns a count plus arbitrary text.
+     *
+     * @param {number} count
+     * @param {string} text
+     * @return {string}
+     */
+    GrCountStringFormatter.computeShortString = function(count, text) {
+      if (count === 0) { return ''; }
+      return count + text;
+    };
+    window.GrCountStringFormatter = GrCountStringFormatter;
+  })(window);
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
new file mode 100644
index 0000000..f33db80
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter_test.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-count-string-formatter</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.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="gr-count-string-formatter.html"/>
+
+<script>
+  suite('gr-count-string-formatter tests', () => {
+    test('computeString', () => {
+      const noun = 'unresolved';
+      assert.equal(GrCountStringFormatter.computeString(0, noun), '');
+      assert.equal(GrCountStringFormatter.computeString(1, noun),
+          '1 unresolved');
+      assert.equal(GrCountStringFormatter.computeString(2, noun),
+          '2 unresolved');
+    });
+
+    test('computeShortString', () => {
+      const noun = 'c';
+      assert.equal(GrCountStringFormatter.computeShortString(0, noun), '');
+      assert.equal(GrCountStringFormatter.computeShortString(1, noun), '1c');
+      assert.equal(GrCountStringFormatter.computeShortString(2, noun), '2c');
+    });
+
+    test('computePluralString', () => {
+      const noun = 'comment';
+      assert.equal(GrCountStringFormatter.computePluralString(0, noun), '');
+      assert.equal(GrCountStringFormatter.computePluralString(1, noun),
+          '1 comment');
+      assert.equal(GrCountStringFormatter.computePluralString(2, noun),
+          '2 comments');
+    });
+  });
+</script>
+
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index 6853a41..fe74906 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -14,21 +14,49 @@
 (function(window) {
   'use strict';
 
+  /**
+   * Ensure GrChangeActionsInterface instance has access to gr-change-actions
+   * element and retrieve if the interface was created before element.
+   * @param {!GrChangeActionsInterface} api
+   */
+  function ensureEl(api) {
+    if (!api._el) {
+      const sharedApiElement = document.createElement('gr-js-api-interface');
+      setEl(api, sharedApiElement.getElement(
+          sharedApiElement.Element.CHANGE_ACTIONS));
+    }
+  }
+
+  /**
+   * Set gr-change-actions element to a GrChangeActionsInterface instance.
+   * @param {!GrChangeActionsInterface} api
+   * @param {!Element} el gr-change-actions
+   */
+  function setEl(api, el) {
+    if (!el) {
+      console.warn('changeActions() is not ready');
+      return;
+    }
+    api._el = el;
+    api.RevisionActions = el.RevisionActions;
+    api.ChangeActions = el.ChangeActions;
+    api.ActionType = el.ActionType;
+  }
+
   function GrChangeActionsInterface(plugin, el) {
     this.plugin = plugin;
-    this._el = el;
-    this.RevisionActions = el.RevisionActions;
-    this.ChangeActions = el.ChangeActions;
-    this.ActionType = el.ActionType;
+    setEl(this, el);
   }
 
   GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
+    ensureEl(this);
     if (this._el.primaryActionKeys.includes(key)) { return; }
 
     this._el.push('primaryActionKeys', key);
   };
 
   GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
+    ensureEl(this);
     this._el.primaryActionKeys = this._el.primaryActionKeys.filter(k => {
       return k !== key;
     });
@@ -36,45 +64,55 @@
 
   GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
       overflow) {
+    ensureEl(this);
     return this._el.setActionOverflow(type, key, overflow);
   };
 
   GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
       priority) {
+    ensureEl(this);
     return this._el.setActionPriority(type, key, priority);
   };
 
   GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
       hidden) {
+    ensureEl(this);
     return this._el.setActionHidden(type, key, hidden);
   };
 
   GrChangeActionsInterface.prototype.add = function(type, label) {
+    ensureEl(this);
     return this._el.addActionButton(type, label);
   };
 
   GrChangeActionsInterface.prototype.remove = function(key) {
+    ensureEl(this);
     return this._el.removeActionButton(key);
   };
 
   GrChangeActionsInterface.prototype.addTapListener = function(key, handler) {
+    ensureEl(this);
     this._el.addEventListener(key + '-tap', handler);
   };
 
   GrChangeActionsInterface.prototype.removeTapListener = function(key,
       handler) {
+    ensureEl(this);
     this._el.removeEventListener(key + '-tap', handler);
   };
 
   GrChangeActionsInterface.prototype.setLabel = function(key, text) {
+    ensureEl(this);
     this._el.setActionButtonProp(key, 'label', text);
   };
 
   GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
+    ensureEl(this);
     this._el.setActionButtonProp(key, 'enabled', enabled);
   };
 
   GrChangeActionsInterface.prototype.getActionDetails = function(action) {
+    ensureEl(this);
     return this._el.getActionDetails(action) ||
       this._el.getActionDetails(this.plugin.getPluginName() + '~' + action);
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 53d7345..21f7629 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -39,6 +39,7 @@
   suite('gr-js-api-interface tests', () => {
     let element;
     let changeActions;
+    let plugin;
 
     // Because deepEqual doesn’t behave in Safari.
     function assertArraysEqual(actual, expected) {
@@ -48,132 +49,152 @@
       }
     }
 
-    setup(() => {
-      element = fixture('basic');
-      element.change = {};
-      element._hasKnownChainState = false;
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeActions = plugin.changeActions();
-    });
+    suite('early init', () => {
+      setup(() => {
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeActions = plugin.changeActions();
+        element = fixture('basic');
+      });
 
-    teardown(() => {
-      changeActions = null;
-    });
+      teardown(() => {
+        changeActions = null;
+      });
 
-    test('property existence', () => {
-      const properties = [
-        'ActionType',
-        'ChangeActions',
-        'RevisionActions',
-      ];
-      for (const p of properties) {
-        assertArraysEqual(changeActions[p], element[p]);
-      }
-    });
-
-    test('add/remove primary action keys', () => {
-      element.primaryActionKeys = [];
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['foo']);
-      changeActions.addPrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
-      changeActions.removePrimaryActionKey('foo');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('baz');
-      assertArraysEqual(element.primaryActionKeys, ['bar']);
-      changeActions.removePrimaryActionKey('bar');
-      assertArraysEqual(element.primaryActionKeys, []);
-    });
-
-    test('action buttons', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const handler = sinon.spy();
-      changeActions.addTapListener(key, handler);
-      flush(() => {
-        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
-        assert(handler.calledOnce);
-        changeActions.removeTapListener(key, handler);
-        MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
-        assert(handler.calledOnce);
-        changeActions.remove(key);
-        flush(() => {
-          assert.isNull(element.$$('[data-action-key="' + key + '"]'));
-          done();
+      test('does not throw', ()=> {
+        assert.doesNotThrow(() => {
+          changeActions.add('change', 'foo');
         });
       });
     });
 
-    test('action button properties', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        const button = element.$$('[data-action-key="' + key + '"]');
-        assert.isOk(button);
-        assert.equal(button.getAttribute('data-label'), 'Bork!');
-        assert.isNotOk(button.disabled);
-        changeActions.setLabel(key, 'Yo');
-        changeActions.setEnabled(key, false);
+    suite('normal init', () => {
+      setup(() => {
+        element = fixture('basic');
+        element.change = {};
+        element._hasKnownChainState = false;
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeActions = plugin.changeActions();
+      });
+
+      teardown(() => {
+        changeActions = null;
+      });
+
+      test('property existence', () => {
+        const properties = [
+          'ActionType',
+          'ChangeActions',
+          'RevisionActions',
+        ];
+        for (const p of properties) {
+          assertArraysEqual(changeActions[p], element[p]);
+        }
+      });
+
+      test('add/remove primary action keys', () => {
+        element.primaryActionKeys = [];
+        changeActions.addPrimaryActionKey('foo');
+        assertArraysEqual(element.primaryActionKeys, ['foo']);
+        changeActions.addPrimaryActionKey('foo');
+        assertArraysEqual(element.primaryActionKeys, ['foo']);
+        changeActions.addPrimaryActionKey('bar');
+        assertArraysEqual(element.primaryActionKeys, ['foo', 'bar']);
+        changeActions.removePrimaryActionKey('foo');
+        assertArraysEqual(element.primaryActionKeys, ['bar']);
+        changeActions.removePrimaryActionKey('baz');
+        assertArraysEqual(element.primaryActionKeys, ['bar']);
+        changeActions.removePrimaryActionKey('bar');
+        assertArraysEqual(element.primaryActionKeys, []);
+      });
+
+      test('action buttons', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+        const handler = sinon.spy();
+        changeActions.addTapListener(key, handler);
         flush(() => {
-          assert.equal(button.getAttribute('data-label'), 'Yo');
-          assert.isTrue(button.disabled);
-          done();
+          MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+          assert(handler.calledOnce);
+          changeActions.removeTapListener(key, handler);
+          MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
+          assert(handler.calledOnce);
+          changeActions.remove(key);
+          flush(() => {
+            assert.isNull(element.$$('[data-action-key="' + key + '"]'));
+            done();
+          });
         });
       });
-    });
 
-    test('hide action buttons', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        const button = element.$$('[data-action-key="' + key + '"]');
-        assert.isOk(button);
-        assert.isFalse(button.hasAttribute('hidden'));
-        changeActions.setActionHidden(
-            changeActions.ActionType.REVISION, key, true);
+      test('action button properties', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
         flush(() => {
           const button = element.$$('[data-action-key="' + key + '"]');
-          assert.isNotOk(button);
-          done();
+          assert.isOk(button);
+          assert.equal(button.getAttribute('data-label'), 'Bork!');
+          assert.isNotOk(button.disabled);
+          changeActions.setLabel(key, 'Yo');
+          changeActions.setEnabled(key, false);
+          flush(() => {
+            assert.equal(button.getAttribute('data-label'), 'Yo');
+            assert.isTrue(button.disabled);
+            done();
+          });
         });
       });
-    });
 
-    test('move action button to overflow', done => {
-      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(() => {
-        assert.isTrue(element.$.moreActions.hidden);
-        assert.isOk(element.$$('[data-action-key="' + key + '"]'));
-        changeActions.setActionOverflow(
-            changeActions.ActionType.REVISION, key, true);
+      test('hide action buttons', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
         flush(() => {
-          assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
-          assert.isFalse(element.$.moreActions.hidden);
-          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
-          done();
+          const button = element.$$('[data-action-key="' + key + '"]');
+          assert.isOk(button);
+          assert.isFalse(button.hasAttribute('hidden'));
+          changeActions.setActionHidden(
+              changeActions.ActionType.REVISION, key, true);
+          flush(() => {
+            const button = element.$$('[data-action-key="' + key + '"]');
+            assert.isNotOk(button);
+            done();
+          });
         });
       });
-    });
 
-    test('change actions priority', done => {
-      const key1 =
+      test('move action button to overflow', done => {
+        const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+        flush(() => {
+          assert.isTrue(element.$.moreActions.hidden);
+          assert.isOk(element.$$('[data-action-key="' + key + '"]'));
+          changeActions.setActionOverflow(
+              changeActions.ActionType.REVISION, key, true);
+          flush(() => {
+            assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
+            assert.isFalse(element.$.moreActions.hidden);
+            assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+            done();
+          });
+        });
+      });
+
+      test('change actions priority', done => {
+        const key1 =
           changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      const key2 =
+        const key2 =
           changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
-      flush(() => {
-        let buttons =
-            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
-        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
-        changeActions.setActionPriority(
-            changeActions.ActionType.REVISION, key1, 10);
         flush(() => {
-          buttons =
+          let buttons =
+            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+          changeActions.setActionPriority(
+              changeActions.ActionType.REVISION, key1, 10);
+          flush(() => {
+            buttons =
               Polymer.dom(element.root).querySelectorAll('[data-action-key]');
-          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
-          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
-          done();
+            assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+            assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+            done();
+          });
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index 65aa364..adaf622 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -15,6 +15,19 @@
   'use strict';
 
   /**
+   * Ensure GrChangeReplyInterface instance has access to gr-reply-dialog
+   * element and retrieve if the interface was created before element.
+   * @param {!GrChangeReplyInterfaceOld} api
+   */
+  function ensureEl(api) {
+    if (!api._el) {
+      const sharedApiElement = document.createElement('gr-js-api-interface');
+      api._el = sharedApiElement.getElement(
+          sharedApiElement.Element.REPLY_DIALOG);
+    }
+  }
+
+  /**
    * @deprecated
    */
   function GrChangeReplyInterfaceOld(el) {
@@ -22,14 +35,17 @@
   }
 
   GrChangeReplyInterfaceOld.prototype.getLabelValue = function(label) {
+    ensureEl(this);
     return this._el.getLabelValue(label);
   };
 
   GrChangeReplyInterfaceOld.prototype.setLabelValue = function(label, value) {
+    ensureEl(this);
     this._el.setLabelValue(label, value);
   };
 
   GrChangeReplyInterfaceOld.prototype.send = function(opt_includeComments) {
+    ensureEl(this);
     return this._el.send(opt_includeComments);
   };
 
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index 2f67035..e453fd2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -40,37 +40,72 @@
     let element;
     let sandbox;
     let changeReply;
+    let plugin;
 
     setup(() => {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({}); },
         getAccount() { return Promise.resolve(null); },
       });
-      element = fixture('basic');
-      sandbox = sinon.sandbox.create();
-      let plugin;
-      Gerrit.install(p => { plugin = p; }, '0.1',
-          'http://test.com/plugins/testplugin/static/test.js');
-      changeReply = plugin.changeReply();
     });
 
     teardown(() => {
-      changeReply = null;
       sandbox.restore();
     });
 
-    test('calls', () => {
-      sandbox.stub(element, 'getLabelValue').returns('+123');
-      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+    suite('early init', () => {
+      setup(() => {
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeReply = plugin.changeReply();
+        element = fixture('basic');
+      });
 
-      sandbox.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert.isTrue(
-          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+      teardown(() => {
+        changeReply = null;
+      });
 
-      sandbox.stub(element, 'send');
-      changeReply.send(false);
-      assert.isTrue(element.send.calledWithExactly(false));
+      test('works', () => {
+        sandbox.stub(element, 'getLabelValue').returns('+123');
+        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+        sandbox.stub(element, 'setLabelValue');
+        changeReply.setLabelValue('My-Label', '+1337');
+        assert.isTrue(
+            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+        sandbox.stub(element, 'send');
+        changeReply.send(false);
+        assert.isTrue(element.send.calledWithExactly(false));
+      });
+    });
+
+    suite('normal init', () => {
+      setup(() => {
+        element = fixture('basic');
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/plugins/testplugin/static/test.js');
+        changeReply = plugin.changeReply();
+      });
+
+      teardown(() => {
+        changeReply = null;
+      });
+
+      test('works', () => {
+        sandbox.stub(element, 'getLabelValue').returns('+123');
+        assert.equal(changeReply.getLabelValue('My-Label'), '+123');
+
+        sandbox.stub(element, 'setLabelValue');
+        changeReply.setLabelValue('My-Label', '+1337');
+        assert.isTrue(
+            element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+        sandbox.stub(element, 'send');
+        changeReply.send(false);
+        assert.isTrue(element.send.calledWithExactly(false));
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
index d30bad2..6159d22 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -68,6 +68,9 @@
         opacity: .6;
         pointer-events: none;
       }
+      a {
+       color: var(--linked-chip-text-color);
+      }
     </style>
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <a href$="[[href]]">
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 5afed95..0ba6f97 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -16,6 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="gr-etag-decorator.html">
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 dcdeb91..45def55 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
@@ -14,6 +14,16 @@
 (function() {
   'use strict';
 
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *    basePatchNum: (string|number),
+   *    patchNum: (number),
+   * }}
+   */
+  Defs.patchRange;
+
   const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -30,11 +40,17 @@
     SEND_DIFF_DRAFT: 'sendDiffDraft',
   };
 
+  const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
+      'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
+  const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
+
+
   Polymer({
     is: 'gr-rest-api-interface',
 
     behaviors: [
       Gerrit.PathListBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -553,25 +569,39 @@
     },
 
     /**
+     * @param {?Object} obj
+     */
+    _updateCachedAccount(obj) {
+      // If result of getAccount is in cache, update it in the cache
+      // so we don't have to invalidate it.
+      const cachedAccount = this._cache['/accounts/self/detail'];
+      if (cachedAccount) {
+        // Replace object in cache with new object to force UI updates.
+        this._cache['/accounts/self/detail'] =
+            Object.assign({}, cachedAccount, obj);
+      }
+    },
+
+    /**
      * @param {string} name
      * @param {function(?Response, string=)=} opt_errFn
      * @param {?=} opt_ctx
      */
     setAccountName(name, opt_errFn, opt_ctx) {
-      return this.send('PUT', '/accounts/self/name', {name}, opt_errFn,
-          opt_ctx).then(response => {
-            // If result of getAccount is in cache, update it in the cache
-            // so we don't have to invalidate it.
-            const cachedAccount = this._cache['/accounts/self/detail'];
-            if (cachedAccount) {
-              return this.getResponseObject(response).then(newName => {
-                // Replace object in cache with new object to force UI updates.
-                // TODO(logan): Polyfill for Object.assign in IE
-                this._cache['/accounts/self/detail'] = Object.assign(
-                    {}, cachedAccount, {name: newName});
-              });
-            }
-          });
+      return this.send('PUT', '/accounts/self/name', {name}, opt_errFn, opt_ctx)
+          .then(response => this.getResponseObject(response)
+              .then(newName => this._updateCachedAccount({name: newName})));
+    },
+
+    /**
+     * @param {string} username
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    setAccountUsername(username, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/username', {username}, opt_errFn,
+          opt_ctx).then(response => this.getResponseObject(response)
+              .then(newName => this._updateCachedAccount({username: newName})));
     },
 
     /**
@@ -581,19 +611,9 @@
      */
     setAccountStatus(status, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/status', {status},
-          opt_errFn, opt_ctx).then(response => {
-            // If result of getAccount is in cache, update it in the cache
-            // so we don't have to invalidate it.
-            const cachedAccount = this._cache['/accounts/self/detail'];
-            if (cachedAccount) {
-              return this.getResponseObject(response).then(newStatus => {
-                // Replace object in cache with new object to force UI updates.
-                // TODO(logan): Polyfill for Object.assign in IE
-                this._cache['/accounts/self/detail'] = Object.assign(
-                    {}, cachedAccount, {status: newStatus});
-              });
-            }
-          });
+          opt_errFn, opt_ctx).then(response => this.getResponseObject(response)
+              .then(newStatus => this._updateCachedAccount(
+                  {status: newStatus})));
     },
 
     getAccountStatus(userId) {
@@ -825,6 +845,7 @@
           this.ListChangesOption.CHANGE_ACTIONS,
           this.ListChangesOption.CURRENT_ACTIONS,
           this.ListChangesOption.DOWNLOAD_COMMANDS,
+          this.ListChangesOption.MESSAGES,
           this.ListChangesOption.SUBMITTABLE,
           this.ListChangesOption.WEB_LINKS
       );
@@ -902,15 +923,18 @@
 
     /**
      * @param {number|string} changeNum
-     * @param {!Promise<?Object>} patchRange
+     * @param {Defs.patchRange} patchRange
+     * @param {number=} opt_parentIndex
      */
-    getChangeFiles(changeNum, patchRange) {
-      let endpoint = '/files';
-      if (patchRange.basePatchNum !== 'PARENT') {
-        endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
+    getChangeFiles(changeNum, patchRange, opt_parentIndex) {
+      let params = undefined;
+      if (this.isMergeParent(patchRange.basePatchNum)) {
+        params = {parent: this.getParentIndex(patchRange.basePatchNum)};
+      } else if (!this.patchNumEquals(patchRange.basePatchNum, 'PARENT')) {
+        params = {base: patchRange.basePatchNum};
       }
-      return this._getChangeURLAndFetch(changeNum, endpoint,
-          patchRange.patchNum);
+      return this._getChangeURLAndFetch(changeNum, '/files',
+          patchRange.patchNum, undefined, undefined, params);
     },
 
     /**
@@ -936,6 +960,11 @@
           `/files?q=${encodeURIComponent(query)}`, patchNum);
     },
 
+    /**
+     * @param {number|string} changeNum
+     * @param {Defs.patchRange} patchRange
+     * @return {!Promise<!Array<!Object>>}
+     */
     getChangeFilesAsSpeciallySortedArray(changeNum, patchRange) {
       return this.getChangeFiles(changeNum, patchRange).then(
           this._normalizeChangeFilesResponse.bind(this));
@@ -1416,7 +1445,8 @@
 
     /**
      * @param {number|string} changeNum
-     * @param {number|string} basePatchNum
+     * @param {number|string} basePatchNum Negative values specify merge parent
+     *     index.
      * @param {number|string} patchNum
      * @param {string} path
      * @param {function(?Response, string=)=} opt_errFn
@@ -1429,7 +1459,9 @@
         intraline: null,
         whitespace: 'IGNORE_NONE',
       };
-      if (basePatchNum != PARENT_PATCH_NUM) {
+      if (this.isMergeParent(basePatchNum)) {
+        params.parent = this.getParentIndex(basePatchNum);
+      } else if (!this.patchNumEquals(basePatchNum, PARENT_PATCH_NUM)) {
         params.base = basePatchNum;
       }
       const endpoint = `/files/${encodeURIComponent(path)}/diff`;
@@ -1610,6 +1642,7 @@
     },
 
     _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
+      const isCreate = !draft.id && method === 'PUT';
       let endpoint = '/drafts';
       if (draft.id) {
         endpoint += '/' + draft.id;
@@ -1626,6 +1659,11 @@
       const promise = this.getChangeURLAndSend(changeNum, method, patchNum,
           endpoint, body);
       this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
+
+      if (isCreate) {
+        return this._failForCreate200(promise);
+      }
+
       return promise;
     },
 
@@ -1970,5 +2008,34 @@
           `/files/${encodedPath}/blame`, patchNum, undefined, undefined,
           opt_base ? {base: 't'} : undefined);
     },
+
+    /**
+     * Modify the given create draft request promise so that it fails and throws
+     * an error if the response bears HTTP status 200 instead of HTTP 201.
+     * @see Issue 7763
+     * @param {Promise} promise The original promise.
+     * @return {Promise} The modified promise.
+     */
+    _failForCreate200(promise) {
+      return promise.then(result => {
+        if (result.status === 200) {
+          // Read the response headers into an object representation.
+          const headers = Array.from(result.headers.entries())
+              .reduce((obj, [key, val]) => {
+                if (!HEADER_REPORTING_BLACKLIST.test(key)) {
+                  obj[key] = val;
+                }
+                return obj;
+              }, {});
+          const err = new Error([
+            CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
+            JSON.stringify(headers),
+          ].join('\n'));
+          // Throw the error so that it is caught by gr-reporting.
+          throw err;
+        }
+        return result;
+      });
+    },
   });
 })();
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 f9604dc..fb5d475 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
@@ -651,24 +651,83 @@
       });
     });
 
-    test('_sendDiffDraft pending requests tracked', () => {
-      const obj = element._pendingRequests;
-      sandbox.stub(element, 'getChangeURLAndSend', () => mockPromise());
-      assert.notOk(element.hasPendingDiffDrafts());
+    suite('draft comments', () => {
+      test('_sendDiffDraftRequest pending requests tracked', () => {
+        const obj = element._pendingRequests;
+        sandbox.stub(element, 'getChangeURLAndSend', () => mockPromise());
+        assert.notOk(element.hasPendingDiffDrafts());
 
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 1);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
+        element._sendDiffDraftRequest(null, null, null, {});
+        assert.equal(obj.sendDiffDraft.length, 1);
+        assert.isTrue(!!element.hasPendingDiffDrafts());
 
-      element._sendDiffDraftRequest(null, null, null, {});
-      assert.equal(obj.sendDiffDraft.length, 2);
-      assert.isTrue(!!element.hasPendingDiffDrafts());
+        element._sendDiffDraftRequest(null, null, null, {});
+        assert.equal(obj.sendDiffDraft.length, 2);
+        assert.isTrue(!!element.hasPendingDiffDrafts());
 
-      for (const promise of obj.sendDiffDraft) { promise.resolve(); }
+        for (const promise of obj.sendDiffDraft) { promise.resolve(); }
 
-      return element.awaitPendingDiffDrafts().then(() => {
-        assert.equal(obj.sendDiffDraft.length, 0);
-        assert.isFalse(!!element.hasPendingDiffDrafts());
+        return element.awaitPendingDiffDrafts().then(() => {
+          assert.equal(obj.sendDiffDraft.length, 0);
+          assert.isFalse(!!element.hasPendingDiffDrafts());
+        });
+      });
+
+      suite('_failForCreate200', () => {
+        test('_sendDiffDraftRequest checks for 200 on create', () => {
+          const sendPromise = Promise.resolve();
+          sandbox.stub(element, 'getChangeURLAndSend').returns(sendPromise);
+          const failStub = sandbox.stub(element, '_failForCreate200')
+              .returns(Promise.resolve());
+          return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
+            assert.isTrue(failStub.calledOnce);
+            assert.isTrue(failStub.calledWithExactly(sendPromise));
+          });
+        });
+
+        test('_sendDiffDraftRequest no checks for 200 on non create', () => {
+          sandbox.stub(element, 'getChangeURLAndSend')
+              .returns(Promise.resolve());
+          const failStub = sandbox.stub(element, '_failForCreate200')
+              .returns(Promise.resolve());
+          return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
+              .then(() => {
+                assert.isFalse(failStub.called);
+              });
+        });
+
+        test('_failForCreate200 fails on 200', done => {
+          const result = {
+            ok: true,
+            status: 200,
+            headers: {entries: () => [
+              ['Set-CoOkiE', 'secret'],
+              ['Innocuous', 'hello'],
+            ]},
+          };
+          element._failForCreate200(Promise.resolve(result)).then(() => {
+            assert.isTrue(false, 'Promise should not resolve');
+          }).catch(e => {
+            assert.isOk(e);
+            assert.include(e.message, 'Saving draft resulted in HTTP 200');
+            assert.include(e.message, 'hello');
+            assert.notInclude(e.message, 'secret');
+            done();
+          });
+        });
+
+        test('_failForCreate200 does not fail on 201', done => {
+          const result = {
+            ok: true,
+            status: 201,
+            headers: {entries: () => []},
+          };
+          element._failForCreate200(Promise.resolve(result)).then(() => {
+            done();
+          }).catch(e => {
+            assert.isTrue(false, 'Promise should not fail');
+          });
+        });
       });
     });
 
@@ -1036,5 +1095,82 @@
         assert.deepEqual(sendSpy.lastCall.args[2], {generate: true});
       });
     });
+
+    suite('getChangeFiles', () => {
+      test('patch only', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        const range = {basePatchNum: 'PARENT', patchNum: 2};
+        return element.getChangeFiles(123, range).then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 2);
+          assert.isNotOk(fetchStub.lastCall.args[5]);
+        });
+      });
+
+      test('simple range', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        const range = {basePatchNum: 4, patchNum: 5};
+        return element.getChangeFiles(123, range).then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.equal(fetchStub.lastCall.args[5].base, 4);
+          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+        });
+      });
+
+      test('parent index', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        const range = {basePatchNum: -3, patchNum: 5};
+        return element.getChangeFiles(123, range).then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].base);
+          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+        });
+      });
+    });
+
+    suite('getDiff', () => {
+      test('patchOnly', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 2);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+          assert.isNotOk(fetchStub.lastCall.args[5].base);
+        });
+      });
+
+      test('simple range', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].parent);
+          assert.equal(fetchStub.lastCall.args[5].base, 4);
+        });
+      });
+
+      test('parent index', () => {
+        const fetchStub = sandbox.stub(element, '_getChangeURLAndFetch')
+            .returns(Promise.resolve());
+        return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
+          assert.isTrue(fetchStub.calledOnce);
+          assert.equal(fetchStub.lastCall.args[2], 5);
+          assert.isOk(fetchStub.lastCall.args[5]);
+          assert.isNotOk(fetchStub.lastCall.args[5].base);
+          assert.equal(fetchStub.lastCall.args[5].parent, 3);
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index a8b8eef..21e5e1f 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -35,7 +35,14 @@
 
     _updateValue() {
       if (this.bindValue) {
+        // Set for chrome/safari so it happens instantly
         this.nativeSelect.value = this.bindValue;
+        // Async needed for firefox to populate value. It was trying to do it
+        // before options from a dom-repeat were rendered previously.
+        // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+        this.async(() => {
+          this.nativeSelect.value = this.bindValue;
+        }, 1);
       }
     },
 
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 7f61edd..add690e 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -15,11 +15,16 @@
   'use strict';
 
   // Date cutoff is one day:
-  const DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
+  const CLEANUP_MAX_AGE = 24 * 60 * 60 * 1000;
 
   // Clean up old entries no more frequently than one day.
   const CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
 
+  const CLEANUP_PREFIXES = [
+    'draft:',
+    'editablecontent:',
+  ];
+
   Polymer({
     is: 'gr-storage',
 
@@ -39,7 +44,7 @@
     },
 
     getDraftComment(location) {
-      this._cleanupDrafts();
+      this._cleanupItems();
       return this._getObject(this._getDraftKey(location));
     },
 
@@ -53,6 +58,16 @@
       this._storage.removeItem(key);
     },
 
+    setEditableContentItem(key, message) {
+      this._setObject(this._getEditableContentKey(key),
+          {message, updated: Date.now()});
+    },
+
+    getEditableContentItem(key) {
+      this._cleanupItems();
+      return this._getObject(this._getEditableContentKey(key));
+    },
+
     getPreferences() {
       return this._getObject('localPrefs');
     },
@@ -74,7 +89,11 @@
       return key;
     },
 
-    _cleanupDrafts() {
+    _getEditableContentKey(key) {
+      return `editablecontent:${key}`;
+    },
+
+    _cleanupItems() {
       // Throttle cleanup to the throttle interval.
       if (this._lastCleanup &&
           Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
@@ -82,12 +101,16 @@
       }
       this._lastCleanup = Date.now();
 
-      let draft;
+      let item;
       for (const key in this._storage) {
-        if (key.startsWith('draft:')) {
-          draft = this._getObject(key);
-          if (Date.now() - draft.updated > DRAFT_MAX_AGE) {
-            this._storage.removeItem(key);
+        if (!this._storage.hasOwnProperty(key)) { continue; }
+        for (const prefix of CLEANUP_PREFIXES) {
+          if (key.startsWith(prefix)) {
+            item = this._getObject(key);
+            if (Date.now() - item.updated > CLEANUP_MAX_AGE) {
+              this._storage.removeItem(key);
+            }
+            break;
           }
         }
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index ce8ec20..b1a77c7 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -33,6 +33,7 @@
 <script>
   suite('gr-storage tests', () => {
     let element;
+    let sandbox;
 
     function mockStorage(opt_quotaExceeded) {
       return {
@@ -48,9 +49,12 @@
 
     setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
       element._storage = mockStorage();
     });
 
+    teardown(() => sandbox.restore());
+
     test('storing, retrieving and erasing drafts', () => {
       const changeNum = 1234;
       const patchNum = 5;
@@ -100,7 +104,7 @@
       // Make sure that the call to cleanup doesn't get throttled.
       element._lastCleanup = 0;
 
-      const cleanupSpy = sinon.spy(element, '_cleanupDrafts');
+      const cleanupSpy = sandbox.spy(element, '_cleanupItems');
 
       // Create a message with a timestamp that is a second behind the max age.
       element._storage.setItem(key, JSON.stringify({
@@ -114,8 +118,6 @@
       assert.isTrue(cleanupSpy.called);
       assert.isNotOk(draft);
       assert.isNotOk(element._storage.getItem(key));
-
-      cleanupSpy.restore();
     });
 
     test('_getDraftKey', () => {
@@ -160,5 +162,28 @@
       assert.isTrue(element._exceededQuota);
       assert.isNotOk(element._storage.getItem(key));
     });
+
+    test('editable content items', () => {
+      const cleanupStub = sandbox.stub(element, '_cleanupItems');
+      const key = 'testKey';
+      const computedKey = element._getEditableContentKey(key);
+      // Key correctly computed.
+      assert.equal(computedKey, 'editablecontent:testKey');
+
+      element.setEditableContentItem(key, 'my content');
+
+      // Setting the draft stores it under the expected key.
+      let item = element._storage.getItem(computedKey);
+      assert.isOk(item);
+      assert.equal(JSON.parse(item).message, 'my content');
+      assert.isOk(JSON.parse(item).updated);
+
+      // getEditableContentItem performs as expected.
+      item = element.getEditableContentItem(key);
+      assert.isOk(item);
+      assert.equal(item.message, 'my content');
+      assert.isOk(item.updated);
+      assert.isTrue(cleanupStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index c34bc69..1744d28 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -24,7 +24,7 @@
         --gr-tooltip-arrow-size: .5em;
         --gr-tooltip-arrow-center-offset: 0;
 
-        background-color: #333;
+        background-color: var(--tooltip-background-color, #333);
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         color: #fff;
         font-size: .75rem;
@@ -52,11 +52,11 @@
         width: 0;
       }
       .arrowPositionAbove {
-        border-top: var(--gr-tooltip-arrow-size) solid #333;
+        border-top: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color, #333);
         bottom: -var(--gr-tooltip-arrow-size);
       }
       .arrowPositionBelow {
-        border-bottom: var(--gr-tooltip-arrow-size) solid #333;
+        border-bottom: var(--gr-tooltip-arrow-size) solid var(--tooltip-background-color, #333);
         top: -var(--gr-tooltip-arrow-size);
       }
     </style>
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index 401f0aa..7c64db3 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -33,6 +33,7 @@
   'GrRangeNormalizer',
   'GrReporting',
   'GrReviewerUpdatesParser',
+  'GrCountStringFormatter',
   'GrThemeApi',
   'moment',
   'page',
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 12e4b5f..a8ccbee 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -17,6 +17,7 @@
 jar_filetype = FileType([".jar"])
 
 LIBS = [
+    "//java/com/google/gerrit/common:version",
     "//java/com/google/gerrit/httpd/init",
     "//lib:postgresql",
     "//lib/bouncycastle:bcpkix",
diff --git a/tools/js/npm_pack.py b/tools/js/npm_pack.py
index 9eb6e34..9e8482e 100755
--- a/tools/js/npm_pack.py
+++ b/tools/js/npm_pack.py
@@ -13,6 +13,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+"""This downloads an NPM binary, and bundles it with its dependencies.
+
+This is used to assemble a pinned version of crisper, hosted on the
+Google storage bucket ("repository=GERRIT" in WORKSPACE).
+"""
+
 from __future__ import print_function
 
 import atexit