Merge "Add username to registration dialog"
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/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-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 45c6e72..7c960dd 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -1,7 +1,5 @@
 = Gerrit Code Review - PolyGerrit Plugin Styling
 
-== Plugin endpoints
-
 Plugin should be html-based and imported following PolyGerrit's
 link:pg-plugin-dev.html#loading[dev guide].
 
@@ -20,13 +18,52 @@
 });
 ```
 
+== 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/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/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/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/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 964dadf..6ecb6e6 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -55,8 +55,7 @@
     Iterable<Object> result = fields.get(fieldDef.getName());
     if (fieldDef.isRepeatable()) {
       return (T) result;
-    } else {
-      return (T) Iterables.getOnlyElement(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 f20bb18..0c261f6 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -31,13 +31,21 @@
 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;
@@ -48,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;
@@ -62,9 +71,13 @@
 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;
@@ -300,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();
@@ -445,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 26623ae..7d7cbef 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -87,6 +87,7 @@
   }
 
   // Make method public so that it can be used in LuceneChangeIndex
+  @Override
   public FieldBundle toFieldBundle(Document doc) {
     return super.toFieldBundle(doc);
   }
@@ -104,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 7a9c3cc..8ceea0d 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -19,7 +19,6 @@
 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.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -29,39 +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 java.util.function.Function;
 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);
@@ -129,88 +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 {
-      return readImpl(LuceneAccountIndex.this::toAccountState);
-    }
-
-    @Override
-    public ResultSet<FieldBundle> readRaw() throws OrmException {
-      return readImpl(LuceneAccountIndex.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, IndexUtils.accountFields(opts));
-          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);
-          }
-        }
-      }
-    }
-  }
-
-  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/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index d479a17..7878afe 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -19,7 +19,6 @@
 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.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -29,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.concurrent.ExecutionException;
-import java.util.function.Function;
 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";
 
@@ -129,85 +114,14 @@
   @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 {
-      return readImpl(LuceneGroupIndex.this::toInternalGroup);
-    }
-
-    @Override
-    public ResultSet<FieldBundle> readRaw() throws OrmException {
-      return readImpl(LuceneGroupIndex.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, IndexUtils.groupFields(opts));
-          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);
-          }
-        }
-      }
-    }
-  }
-
-  private 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).
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 3e6f50a..e776a8b 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -19,7 +19,6 @@
 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.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Project;
@@ -29,39 +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 java.util.function.Function;
 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);
@@ -129,85 +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 {
-      return readImpl(LuceneProjectIndex.this::toProjectData);
-    }
-
-    @Override
-    public ResultSet<FieldBundle> readRaw() throws OrmException {
-      return readImpl(LuceneProjectIndex.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, IndexUtils.projectFields(opts));
-          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);
-          }
-        }
-      }
-    }
-  }
-
-  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 e6f05cf..3385244 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -42,6 +42,7 @@
 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;
@@ -72,20 +73,14 @@
   private final InitFlags flags;
   private final SitePaths site;
   private final String allUsers;
-  private final boolean readFromNoteDb;
-  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();
-    readFromNoteDb = flags.cfg.getBoolean("user", null, "readGroupsFromNoteDb", false);
-    // 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);
   }
 
   /**
@@ -101,7 +96,7 @@
    */
   public InternalGroup getExistingGroup(ReviewDb db, GroupReference groupReference)
       throws OrmException, NoSuchGroupException, IOException, ConfigInvalidException {
-    if (readFromNoteDb) {
+    if (groupsMigration.readFromNoteDb()) {
       return getExistingGroupFromNoteDb(groupReference);
     }
 
@@ -151,7 +146,7 @@
    */
   public Stream<GroupReference> getAllGroupReferences(ReviewDb db)
       throws OrmException, IOException, ConfigInvalidException {
-    if (readFromNoteDb) {
+    if (groupsMigration.readFromNoteDb()) {
       File allUsersRepoPath = getPathToAllUsersRepository();
       if (allUsersRepoPath != null) {
         try (Repository allUsersRepo = new FileRepository(allUsersRepoPath)) {
@@ -181,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/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 1839d11..995b020 100644
--- a/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -224,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/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..efc8a01 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;
@@ -679,4 +685,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/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/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/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/GetAuditLog.java b/java/com/google/gerrit/server/group/GetAuditLog.java
index ebada0b..58a057b 100644
--- a/java/com/google/gerrit/server/group/GetAuditLog.java
+++ b/java/com/google/gerrit/server/group/GetAuditLog.java
@@ -36,10 +36,12 @@
 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;
 
 @Singleton
 public class GetAuditLog implements RestReadView<GroupResource> {
@@ -68,7 +70,8 @@
 
   @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()) {
@@ -123,8 +126,9 @@
 
     accountLoader.fill();
 
-    // sort by date in reverse order so that the newest audit event comes first
-    Collections.sort(auditEvents, comparing((GroupAuditEventInfo a) -> a.date).reversed());
+    // sort by date and then reverse so that the newest audit event comes first
+    Collections.sort(auditEvents, comparing((GroupAuditEventInfo a) -> a.date));
+    Collections.reverse(auditEvents);
 
     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/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/db/AuditLogReader.java b/java/com/google/gerrit/server/group/db/AuditLogReader.java
new file mode 100644
index 0000000..3ab91dd
--- /dev/null
+++ b/java/com/google/gerrit/server/group/db/AuditLogReader.java
@@ -0,0 +1,274 @@
+// 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.AllUsersName;
+import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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;
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+
+  @Inject
+  AuditLogReader(
+      @GerritServerId String serverId, GitRepositoryManager repoManager, AllUsersName allUsers) {
+    this.serverId = serverId;
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+  }
+
+  // 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(AccountGroup.UUID uuid)
+      throws IOException, ConfigInvalidException {
+    return new MembersAuditLogParser().parseAuditLog(uuid);
+  }
+
+  ImmutableList<AccountGroupByIdAud> getSubgroupsAudit(AccountGroup.UUID uuid)
+      throws IOException, ConfigInvalidException {
+    return new SubgroupsAuditLogParser().parseAuditLog(uuid);
+  }
+
+  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 abstract class AuditLogParser<T> {
+    final ImmutableList<T> parseAuditLog(AccountGroup.UUID uuid)
+        throws IOException, ConfigInvalidException {
+      try (Repository repo = repoManager.openRepository(allUsers);
+          RevWalk rw = new RevWalk(repo)) {
+        Ref ref = repo.exactRef(RefNames.refsGroups(uuid));
+        if (ref == null) {
+          return ImmutableList.of();
+        }
+
+        // TODO(dborowitz): This re-walks all commits just to find createdOn, which we don't need.
+        AccountGroup.Id groupId =
+            GroupConfig.loadForGroup(repo, uuid).getLoadedGroup().get().getId();
+
+        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<T> result = ImmutableList.builder();
+        RevCommit c;
+        while ((c = rw.next()) != null) {
+          parse(uuid, c).ifPresent(pc -> visit(groupId, pc, result));
+        }
+        return result.build();
+      }
+    }
+
+    protected abstract void visit(
+        AccountGroup.Id groupId, ParsedCommit pc, ImmutableList.Builder<T> result);
+  }
+
+  private class MembersAuditLogParser extends AuditLogParser<AccountGroupMemberAudit> {
+    private ListMultimap<MemberKey, AccountGroupMemberAudit> audits =
+        MultimapBuilder.hashKeys().linkedListValues().build();
+
+    @Override
+    protected void visit(
+        AccountGroup.Id groupId,
+        ParsedCommit pc,
+        ImmutableList.Builder<AccountGroupMemberAudit> result) {
+      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);
+        }
+      }
+    }
+  }
+
+  private class SubgroupsAuditLogParser extends AuditLogParser<AccountGroupByIdAud> {
+    private ListMultimap<SubgroupKey, AccountGroupByIdAud> audits =
+        MultimapBuilder.hashKeys().linkedListValues().build();
+
+    @Override
+    protected void visit(
+        AccountGroup.Id groupId,
+        ParsedCommit pc,
+        ImmutableList.Builder<AccountGroupByIdAud> result) {
+      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.
+        }
+      }
+    }
+  }
+
+  @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
index f83f094..2f3b118 100644
--- a/java/com/google/gerrit/server/group/db/GroupBundle.java
+++ b/java/com/google/gerrit/server/group/db/GroupBundle.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.group.db;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 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.ImmutableList;
+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;
@@ -28,6 +28,11 @@
 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.
@@ -37,17 +42,84 @@
  */
 @AutoValue
 public abstract class GroupBundle {
-  public static 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");
+  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;
     }
-    return create(
-        group,
-        db.accountGroupMembers().byGroup(id),
-        db.accountGroupMembersAudit().byGroup(id),
-        db.accountGroupById().byGroup(id),
-        db.accountGroupByIdAud().byGroup(id));
+
+    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(uuid),
+          internalGroup
+              .getSubgroups()
+              .stream()
+              .map(
+                  subgroupUuid ->
+                      new AccountGroupById(new AccountGroupById.Key(groupId, subgroupUuid)))
+              .collect(toImmutableSet()),
+          auditLogReader.getSubgroupsAudit(uuid));
+    }
   }
 
   public static GroupBundle create(
@@ -79,13 +151,13 @@
 
   public abstract AccountGroup group();
 
-  public abstract ImmutableList<AccountGroupMember> members();
+  public abstract ImmutableSet<AccountGroupMember> members();
 
-  public abstract ImmutableList<AccountGroupMemberAudit> memberAudit();
+  public abstract ImmutableSet<AccountGroupMemberAudit> memberAudit();
 
-  public abstract ImmutableList<AccountGroupById> byId();
+  public abstract ImmutableSet<AccountGroupById> byId();
 
-  public abstract ImmutableList<AccountGroupByIdAud> byIdAudit();
+  public abstract ImmutableSet<AccountGroupByIdAud> byIdAudit();
 
   public abstract Builder toBuilder();
 
@@ -97,8 +169,8 @@
     return toBuilder()
         .group(newGroup)
         .memberAudit(
-            memberAudit().stream().map(GroupBundle::roundToSecond).collect(toImmutableList()))
-        .byIdAudit(byIdAudit().stream().map(GroupBundle::roundToSecond).collect(toImmutableList()))
+            memberAudit().stream().map(GroupBundle::roundToSecond).collect(toImmutableSet()))
+        .byIdAudit(byIdAudit().stream().map(GroupBundle::roundToSecond).collect(toImmutableSet()))
         .build();
   }
 
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index 8ac533c..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");
@@ -123,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;
@@ -134,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())
@@ -142,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()) {
@@ -184,7 +204,8 @@
                 config,
                 updatedMembers.orElse(originalMembers),
                 updatedSubgroups.orElse(originalSubgroups),
-                createdOn));
+                createdOn,
+                null));
     groupCreation = Optional.empty();
 
     return true;
@@ -318,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);
   }
 
@@ -333,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/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index cd8effd..ee7f849 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -15,9 +15,12 @@
 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;
@@ -27,20 +30,25 @@
 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 {
@@ -48,6 +56,63 @@
   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;
@@ -175,6 +240,7 @@
     }
 
     commit.setTreeId(noteMap.writeTree(inserter));
+    commit.setMessage(getCommitMessage());
 
     return true;
   }
@@ -197,7 +263,8 @@
 
   // Use the same approach as ExternalId.Key.sha1().
   @SuppressWarnings("deprecation")
-  private static ObjectId getNoteKey(AccountGroup.NameKey groupName) {
+  @VisibleForTesting
+  static ObjectId getNoteKey(AccountGroup.NameKey groupName) {
     return ObjectId.fromRaw(Hashing.sha1().hashString(groupName.get(), UTF_8).asBytes());
   }
 
@@ -208,7 +275,8 @@
     return config.toText();
   }
 
-  private static GroupReference getGroupReference(ObjectReader reader, ObjectId noteDataBlobId)
+  @VisibleForTesting
+  public static GroupReference getGroupReference(ObjectReader reader, ObjectId noteDataBlobId)
       throws IOException, ConfigInvalidException {
     byte[] noteData = reader.open(noteDataBlobId, OBJ_BLOB).getCachedBytes();
     return getFromNoteData(noteData);
@@ -227,4 +295,18 @@
 
     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
index b5301e5..7f8d8a9 100644
--- a/java/com/google/gerrit/server/group/db/GroupRebuilder.java
+++ b/java/com/google/gerrit/server/group/db/GroupRebuilder.java
@@ -15,14 +15,18 @@
 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;
@@ -46,11 +50,14 @@
 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;
@@ -107,7 +114,7 @@
     this.getGroupNameFunc = getGroupNameFunc;
   }
 
-  public void rebuild(Repository allUsersRepo, GroupBundle bundle)
+  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();
@@ -128,10 +135,12 @@
     }
     groupConfig.setGroupUpdate(updateBuilder.build(), getAccountNameEmailFunc, getGroupNameFunc);
 
-    MetaDataUpdate md = metaDataUpdateFactory.create(allUsers, allUsersRepo, null);
+    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 nowServerIdent = serverIdent.get();
     PersonIdent created = new PersonIdent(nowServerIdent, group.getCreatedOn());
     md.getCommitBuilder().setAuthor(created);
     md.getCommitBuilder().setCommitter(created);
@@ -140,7 +149,6 @@
     try (BatchMetaDataUpdate batch = groupConfig.openUpdate(md)) {
       batch.write(groupConfig, md.getCommitBuilder());
 
-      Map<Key, Collection<Event>> events = toEvents(bundle, nowServerIdent).asMap();
       for (Map.Entry<Key, Collection<Event>> e : events.entrySet()) {
         InternalGroupUpdate.Builder ub = InternalGroupUpdate.builder();
         e.getValue().forEach(event -> event.update().accept(ub));
@@ -161,7 +169,7 @@
     }
   }
 
-  private ListMultimap<Key, Event> toEvents(GroupBundle bundle, PersonIdent nowServerIdent) {
+  private ListMultimap<Key, Event> toEvents(GroupBundle bundle) {
     ListMultimap<Key, Event> result =
         MultimapBuilder.treeKeys(Key.COMPARATOR).arrayListValues(1).build();
     Event e;
@@ -196,13 +204,32 @@
       }
     }
 
-    Timestamp now = new Timestamp(nowServerIdent.getWhen().getTime());
-    e = serverEvent(Type.FIXUP, now, setCurrentMembership(bundle));
+    // 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();
diff --git a/java/com/google/gerrit/server/group/db/Groups.java b/java/com/google/gerrit/server/group/db/Groups.java
index 0260f41..7f63021 100644
--- a/java/com/google/gerrit/server/group/db/Groups.java
+++ b/java/com/google/gerrit/server/group/db/Groups.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.group.db;
 
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
@@ -30,20 +31,20 @@
 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;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Collections;
 import java.util.List;
 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;
 
 /**
@@ -61,18 +62,21 @@
  */
 @Singleton
 public class Groups {
-  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 +109,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);
       }
@@ -182,7 +186,7 @@
    */
   public Stream<GroupReference> getAllGroupReferences(ReviewDb db)
       throws OrmException, IOException, ConfigInvalidException {
-    if (readFromNoteDb) {
+    if (groupsMigration.readFromNoteDb()) {
       try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
         return GroupNameNotes.loadAllGroupReferences(allUsersRepo).stream();
       }
@@ -278,7 +282,7 @@
    */
   public Stream<AccountGroup.UUID> getExternalGroups(ReviewDb db)
       throws OrmException, IOException, ConfigInvalidException {
-    if (readFromNoteDb) {
+    if (groupsMigration.readFromNoteDb()) {
       try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
         return getExternalGroupsFromNoteDb(allUsersRepo);
       }
@@ -312,18 +316,23 @@
    * @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");
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return auditLogReader.getMembersAudit(groupUuid);
     }
     Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
     if (!group.isPresent()) {
       return ImmutableList.of();
     }
-    return db.accountGroupMembersAudit().byGroup(group.get().getId()).toList();
+
+    List<AccountGroupMemberAudit> audits =
+        db.accountGroupMembersAudit().byGroup(group.get().getId()).toList();
+    Collections.sort(audits, comparing((AccountGroupMemberAudit a) -> a.getAddedOn()));
+    return audits;
   }
 
   /**
@@ -333,17 +342,22 @@
    * @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");
+      throws OrmException, IOException, ConfigInvalidException {
+    if (groupsMigration.readFromNoteDb()) {
+      return auditLogReader.getSubgroupsAudit(groupUuid);
     }
     Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
     if (!group.isPresent()) {
       return ImmutableList.of();
     }
-    return db.accountGroupByIdAud().byGroup(group.get().getId()).toList();
+
+    List<AccountGroupByIdAud> audits =
+        db.accountGroupByIdAud().byGroup(group.get().getId()).toList();
+    Collections.sort(audits, comparing((AccountGroupByIdAud a) -> a.getAddedOn()));
+    return audits;
   }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 2e67ec4..80b282c 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;
@@ -48,6 +49,7 @@
 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;
@@ -62,9 +64,9 @@
 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;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * A database accessor for write calls related to groups.
@@ -104,8 +106,9 @@
   @Nullable private final IdentifiedUser currentUser;
   private final PersonIdent authorIdent;
   private final MetaDataUpdateFactory metaDataUpdateFactory;
+  private final GroupsMigration groupsMigration;
   private final GitReferenceUpdated gitRefUpdated;
-  private final boolean writeGroupsToNoteDb;
+  private final boolean reviewDbUpdatesAreBlocked;
 
   @Inject
   GroupsUpdate(
@@ -120,6 +123,7 @@
       @GerritServerId String serverId,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GroupsMigration groupsMigration,
       @GerritServerConfig Config config,
       GitReferenceUpdated gitRefUpdated,
       @Assisted @Nullable IdentifiedUser currentUser) {
@@ -132,17 +136,14 @@
     this.anonymousCowardName = anonymousCowardName;
     this.renameGroupOpFactory = renameGroupOpFactory;
     this.serverId = serverId;
+    this.groupsMigration = groupsMigration;
     this.gitRefUpdated = gitRefUpdated;
     this.currentUser = currentUser;
     metaDataUpdateFactory =
         getMetaDataUpdateFactory(
             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);
   }
 
   private static MetaDataUpdateFactory getMetaDataUpdateFactory(
@@ -204,9 +205,11 @@
   public InternalGroup createGroup(
       ReviewDb db, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
       throws OrmException, IOException, ConfigInvalidException {
-    InternalGroup createdGroupInReviewDb = createGroupInReviewDb(db, groupCreation, groupUpdate);
+    // TODO(ekempin): Don't read groups from ReviewDb if reading groups from NoteDb is configured
+    InternalGroup createdGroupInReviewDb =
+        createGroupInReviewDb(ReviewDbUtil.unwrapDb(db), groupCreation, groupUpdate);
 
-    if (!writeGroupsToNoteDb) {
+    if (!groupsMigration.writeToNoteDb()) {
       updateCachesOnGroupCreation(createdGroupInReviewDb);
       return createdGroupInReviewDb;
     }
@@ -240,10 +243,12 @@
   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);
+    // TODO(ekempin): Don't read groups from ReviewDb if reading groups from NoteDb is configured
+    AccountGroup group = getExistingGroupFromReviewDb(ReviewDbUtil.unwrapDb(db), groupUuid);
+    UpdateResult reviewDbUpdateResult =
+        updateGroupInReviewDb(ReviewDbUtil.unwrapDb(db), group, groupUpdate);
 
-    if (!writeGroupsToNoteDb) {
+    if (!groupsMigration.writeToNoteDb()) {
       return reviewDbUpdateResult;
     }
 
@@ -255,6 +260,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
@@ -264,7 +270,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(
@@ -291,6 +300,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();
@@ -504,7 +515,8 @@
             .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());
     }
@@ -574,9 +586,7 @@
       }
     }
 
-    try (RevWalk revWalk = new RevWalk(allUsersRepo)) {
-      RefUpdateUtil.executeChecked(batchRefUpdate, revWalk);
-    }
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
     gitRefUpdated.fire(
         allUsersName, batchRefUpdate, currentUser != null ? currentUser.getAccount() : null);
   }
@@ -616,6 +626,12 @@
     }
   }
 
+  private void checkIfReviewDbUpdatesAreBlocked() throws OrmException {
+    if (reviewDbUpdatesAreBlocked) {
+      throw new OrmException("Updates to groups in ReviewDb are blocked");
+    }
+  }
+
   @FunctionalInterface
   private interface MetaDataUpdateFactory {
     MetaDataUpdate create(
@@ -637,6 +653,9 @@
 
     abstract ImmutableSet<AccountGroup.UUID> getModifiedSubgroups();
 
+    @Nullable
+    public abstract ObjectId getRefState();
+
     static Builder builder() {
       return new AutoValue_GroupsUpdate_UpdateResult.Builder();
     }
@@ -655,6 +674,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/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/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/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..221252c 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.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.
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..1ca67a4
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/GroupsMigration.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+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 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;
+
+  @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));
+  }
+
+  public GroupsMigration(boolean writeToNoteDb, boolean readFromNoteDb) {
+    this.writeToNoteDb = writeToNoteDb;
+    this.readFromNoteDb = readFromNoteDb;
+  }
+
+  public boolean writeToNoteDb() {
+    return writeToNoteDb;
+  }
+
+  public boolean readFromNoteDb() {
+    return readFromNoteDb;
+  }
+
+  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());
+    }
+  }
+}
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..bab756e 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";
 
   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/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/NotesMigrationSchemaFactory.java b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
index d73a5f4..c80e06b 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,48 +28,58 @@
 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;
+    if (migration.readChanges()) {
+      // There are two levels at which this class disables access to Changes and related tables,
+      // corresponding to two phases of the NoteDb migration:
+      //
+      // 1. When changes are read from NoteDb but some changes might still have their primary
+      //    storage in ReviewDb, it is generally programmer error to read changes from ReviewDb.
+      //    However, since ReviewDb is still the primary storage for most or all changes, we still
+      //    need to 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.
+      //
+      // 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.
+
+      if (migration.disableChangeReviewDb()) {
+        db = new NoChangesReviewDbWrapper(db);
+      }
+      db = new DisallowReadFromChangesReviewDbWrapper(db);
     }
 
-    // There are two levels at which this class disables access to Changes and related tables,
-    // corresponding to two phases of the NoteDb migration:
-    //
-    // 1. When changes are read from NoteDb but some changes might still have their primary storage
-    //    in ReviewDb, it is generally programmer error to read changes from ReviewDb. However,
-    //    since ReviewDb is still the primary storage for most or all changes, we still need to
-    //    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.
-    //
-    // 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.
-
-    if (migration.disableChangeReviewDb()) {
-      db = new NoChangesReviewDbWrapper(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 new DisallowReadFromChangesReviewDbWrapper(db);
+    return db;
   }
 }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreator.java b/java/com/google/gerrit/server/schema/SchemaCreator.java
index eb05360..112fd55 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -45,6 +45,7 @@
 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;
@@ -60,7 +61,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
 
 /** Creates the current database schema and populates initial code rows. */
 public class SchemaCreator {
@@ -73,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;
@@ -90,6 +90,7 @@
       @GerritPersonIdent PersonIdent au,
       DataSourceType dst,
       GroupIndexCollection ic,
+      GroupsMigration gm,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
       NotesMigration migration,
@@ -103,6 +104,7 @@
         au,
         dst,
         ic,
+        gm,
         config,
         metricMaker,
         migration,
@@ -118,6 +120,7 @@
       @GerritPersonIdent PersonIdent au,
       DataSourceType dst,
       GroupIndexCollection ic,
+      GroupsMigration gm,
       Config config,
       MetricMaker metricMaker,
       NotesMigration migration,
@@ -130,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;
@@ -214,7 +213,7 @@
       throws OrmException, ConfigInvalidException, IOException {
     InternalGroup groupInReviewDb = createGroupInReviewDb(db, groupCreation, groupUpdate);
 
-    if (!writeGroupsToNoteDb) {
+    if (!groupsMigration.writeToNoteDb()) {
       index(groupInReviewDb);
       return;
     }
@@ -262,9 +261,7 @@
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate(allUsersRepo, batchRefUpdate)) {
       groupNameNotes.commit(metaDataUpdate);
     }
-    try (RevWalk revWalk = new RevWalk(allUsersRepo)) {
-      RefUpdateUtil.executeChecked(batchRefUpdate, revWalk);
-    }
+    RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
   }
 
   private MetaDataUpdate createMetaDataUpdate(
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..9d8e557
--- /dev/null
+++ b/java/com/google/gerrit/testing/GroupNoteDbMode.java
@@ -0,0 +1,71 @@
+// 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)),
+
+  /** Writing new groups to NoteDb is enabled. */
+  WRITE(new GroupsMigration(true, false)),
+
+  /**
+   * Reading/writing groups from/to NoteDb is enabled. Trying to read groups from ReviewDb throws an
+   * exception.
+   */
+  READ_WRITE(new GroupsMigration(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..c59a2f6 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;
@@ -85,6 +86,7 @@
 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;
@@ -94,6 +96,8 @@
 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 +107,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 +119,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 +128,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 +163,8 @@
 
   @Inject private ExternalIdsUpdate.User externalIdsUpdateFactory;
 
+  @Inject private ExternalIdsUpdate.ServerNoReindex externalIdsUpdateNoReindexFactory;
+
   @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
 
   @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
@@ -163,6 +175,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 +938,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 +954,7 @@
         pushFactory
             .create(
                 db,
-                admin.getIdent(),
+                foo.getIdent(),
                 allUsersRepo,
                 "Update account config",
                 AccountConfig.ACCOUNT_CONFIG,
@@ -1058,7 +1078,10 @@
   }
 
   @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);
@@ -1229,18 +1252,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 +1271,7 @@
     pushFactory
         .create(
             db,
-            admin.getIdent(),
+            foo.getIdent(),
             allUsersRepo,
             "Update account config",
             AccountConfig.ACCOUNT_CONFIG,
@@ -1268,7 +1288,7 @@
 
   @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();
 
@@ -1276,7 +1296,7 @@
         groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null);
     grant(allUsers, userRef, Permission.PUSH, false, adminGroup.getGroupUUID());
 
-    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers);
+    TestRepository<InMemoryRepository> allUsersRepo = cloneProject(allUsers, foo);
     fetch(allUsersRepo, userRef + ":userRef");
     allUsersRepo.reset("userRef");
 
@@ -1287,7 +1307,7 @@
     pushFactory
         .create(
             db,
-            admin.getIdent(),
+            foo.getIdent(),
             allUsersRepo,
             "Update account config",
             AccountConfig.ACCOUNT_CONFIG,
@@ -1326,7 +1346,10 @@
   }
 
   @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);
@@ -1776,6 +1799,75 @@
     assertGroups(newUser, ImmutableList.of(group));
   }
 
+  @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/group/GroupRebuilderIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
index 3437cf4..9f98895 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
@@ -15,26 +15,24 @@
 package com.google.gerrit.acceptance.api.group;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.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.common.TimeUtil;
 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.InternalGroup;
 import com.google.gerrit.server.group.ServerInitiated;
 import com.google.gerrit.server.group.db.GroupBundle;
-import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupRebuilder;
-import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.testing.ConfigSuite;
 import com.google.gerrit.testing.TestTimeUtil;
@@ -44,7 +42,6 @@
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.List;
-import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -64,15 +61,15 @@
   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 writeGroupsToNoteDb, however.
-    config.setBoolean("user", null, "readGroupsFromNoteDb", false);
+    // 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;
-  @Inject private Groups groups;
 
   @Before
   public void setTimeForTesting() {
@@ -87,10 +84,13 @@
   @Test
   public void basicGroupProperties() throws Exception {
     GroupInfo createdGroup = gApi.groups().create(name("group")).get();
-    InternalGroup reviewDbGroup = groups.getGroup(db, new AccountGroup.UUID(createdGroup.id)).get();
-    deleteGroupRefs(reviewDbGroup);
+    try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(createdGroup.groupId));
+      deleteGroupRefs(reviewDbBundle);
 
-    assertThat(rebuild(reviewDbGroup)).isEqualTo(roundToSecond(reviewDbGroup));
+      assertThat(rebuild(reviewDbBundle)).isEqualTo(reviewDbBundle.roundToSecond());
+    }
   }
 
   @Test
@@ -105,51 +105,54 @@
 
     gApi.groups().id(group1.id).addGroups(group2.id);
 
-    InternalGroup reviewDbGroup = groups.getGroup(db, new AccountGroup.UUID(group1.id)).get();
-    deleteGroupRefs(reviewDbGroup);
+    try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(group1.groupId));
+      deleteGroupRefs(reviewDbBundle);
 
-    InternalGroup noteDbGroup = rebuild(reviewDbGroup);
-    assertThat(noteDbGroup).isEqualTo(roundToSecond(reviewDbGroup));
+      GroupBundle noteDbBundle = rebuild(reviewDbBundle);
+      assertThat(noteDbBundle).isEqualTo(reviewDbBundle.roundToSecond());
 
-    ImmutableList<CommitInfo> log = log(group1);
-    assertThat(log).hasSize(4);
+      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(noteDbGroup.getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.get().getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
+      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(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(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);
+      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(InternalGroup group) throws Exception {
+  private void deleteGroupRefs(GroupBundle bundle) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      String refName = RefNames.refsGroups(group.getGroupUUID());
+      String refName = RefNames.refsGroups(bundle.uuid());
       RefUpdate ru = repo.updateRef(refName);
       ru.setForceUpdate(true);
       Ref oldRef = repo.exactRef(refName);
@@ -162,30 +165,13 @@
     }
   }
 
-  private InternalGroup rebuild(InternalGroup group) throws Exception {
+  private GroupBundle rebuild(GroupBundle reviewDbBundle) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      rebuilder.rebuild(repo, GroupBundle.fromReviewDb(db, group.getId()));
-      GroupConfig groupConfig = GroupConfig.loadForGroup(repo, group.getGroupUUID());
-      Optional<InternalGroup> result = groupConfig.getLoadedGroup();
-      assertThat(result).isPresent();
-      return result.get();
+      rebuilder.rebuild(repo, reviewDbBundle, null);
+      return bundleFactory.fromNoteDb(repo, reviewDbBundle.uuid());
     }
   }
 
-  private InternalGroup roundToSecond(InternalGroup g) {
-    return InternalGroup.builder()
-        .setId(g.getId())
-        .setNameKey(g.getNameKey())
-        .setDescription(g.getDescription())
-        .setOwnerGroupUUID(g.getOwnerGroupUUID())
-        .setVisibleToAll(g.isVisibleToAll())
-        .setGroupUUID(g.getGroupUUID())
-        .setCreatedOn(TimeUtil.roundToSecond(g.getCreatedOn()))
-        .setMembers(g.getMembers())
-        .setSubgroups(g.getSubgroups())
-        .build();
-  }
-
   private ImmutableList<CommitInfo> log(GroupInfo g) throws Exception {
     ImmutableList.Builder<CommitInfo> result = ImmutableList.builder();
     List<Date> commitDates = new ArrayList<>();
@@ -205,4 +191,19 @@
     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 c090558..ba92835 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -22,8 +22,14 @@
 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.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,8 +39,13 @@
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 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.changes.ReviewInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -50,19 +61,27 @@
 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;
 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.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gerrit.server.git.ProjectConfig;
 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.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;
@@ -73,6 +92,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -83,6 +103,7 @@
 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;
@@ -93,13 +114,20 @@
   @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;
   }
 
   @Inject private Groups groups;
   @Inject private GroupIncludeCache groupIncludeCache;
+  @Inject private GroupBackend groupBackend;
+  @Inject private StalenessChecker stalenessChecker;
+  @Inject private GroupIndexer groupIndexer;
+
+  @Inject
+  @Named("groups_byuuid")
+  private LoadingCache<String, Optional<InternalGroup>> groupsByUUIDCache;
 
   @Test
   public void systemGroupCanBeRetrievedFromIndex() throws Exception {
@@ -750,7 +778,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);
@@ -778,10 +805,30 @@
     assertThat(auditEvents).hasSize(5);
     assertAuditEvent(auditEvents.get(0), Type.REMOVE_GROUP, admin.id, otherGroup);
 
+    /**
+     * Make sure the new commit is created in a different second. This is added for NoteDb since the
+     * resolution of Timestamp is 1s there. Adding here is enough because the sort used in {@code
+     * GetAuditLog} is stable and we process {@code AccountGroupMemberAudit} before {@code
+     * AccountGroupByIdAud}.
+     */
+    Thread.sleep(1000);
+
+    // 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;
     }
@@ -815,44 +862,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("fo")).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("fo")).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);
@@ -861,6 +921,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");
@@ -935,6 +1018,129 @@
     }
   }
 
+  @Test
+  public void defaultPermissionsOnGroupBranches() throws Exception {
+    assertPermission(
+        allUsers, RefNames.REFS_GROUPS + "*", Permission.READ, groupRef(REGISTERED_USERS));
+  }
+
+  @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");
+  }
+
+  private GroupReference groupRef(AccountGroup.UUID groupUuid) {
+    GroupDescription.Basic groupDescription = groupBackend.get(groupUuid);
+    return new GroupReference(groupDescription.getGroupUUID(), groupDescription.getName());
+  }
+
+  private void assertPermission(
+      Project.NameKey project, String ref, String permission, GroupReference groupReference)
+      throws IOException {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccessSection accessSection = cfg.getAccessSection(ref);
+    assertThat(accessSection).isNotNull();
+    Permission readPermission = accessSection.getPermission(permission);
+    assertThat(readPermission).isNotNull();
+    assertThat(readPermission.getName()).isEqualTo(permission);
+    assertThat(readPermission.getExclusiveGroup()).isTrue();
+    assertThat(readPermission.getLabel()).isNull();
+
+    PermissionRule rule = readPermission.getRule(groupReference);
+    assertThat(rule).isNotNull();
+    assertThat(rule.getGroup()).isEqualTo(groupReference);
+    assertThat(rule.getAction()).isEqualTo(Action.ALLOW);
+    assertThat(rule.getForce()).isFalse();
+    assertThat(rule.getMin()).isEqualTo(0);
+    assertThat(rule.getMax()).isEqualTo(0);
+  }
+
+  @Test
+  public void stalenessChecker() throws Exception {
+    assume().that(groupsInNoteDb()).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(
@@ -1053,7 +1259,7 @@
   }
 
   private boolean groupsInNoteDb() {
-    return cfg.getBoolean("user", "writeGroupsToNoteDb", false)
-        && cfg.getBoolean("user", "readGroupsFromNoteDb", false);
+    return cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), WRITE, false)
+        && cfg.getBoolean(SECTION_NOTE_DB, GROUPS.key(), READ, false);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index a6dcc62..0adfafd 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;
@@ -83,19 +95,32 @@
   @Before
   public void setUp() throws Exception {
     admins = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID();
+    nonInteractiveUsers =
+        groupCache
+            .get(new AccountGroup.NameKey("Non-Interactive Users"))
+            .orElse(null)
+            .getGroupUUID();
     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 +472,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 +598,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 +630,7 @@
   }
 
   @Test
+  @Sandboxed
   public void advertisedReferencesOmitStarredChangesRefsOfOtherUsers() throws Exception {
     assume().that(notesMigration.commitChangeWrites()).isTrue();
 
@@ -526,6 +648,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 +706,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 +779,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/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index b150df3..ba845e5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -103,7 +103,7 @@
   }
 
   @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);
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 3abc581..c8d92c7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -140,7 +140,7 @@
   }
 
   @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);
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/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..321580c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -0,0 +1,381 @@
+// 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, repoManager, allUsersName);
+  }
+
+  @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(uuid)).containsExactly(expAudit);
+  }
+
+  @Test
+  public void createGroupAsServerIdent() throws Exception {
+    InternalGroup group = createGroup(1, "test-group", serverIdent, null);
+    assertThat(auditLogReader.getMembersAudit(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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
index 0c30070..e2177e2 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.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.common.collect.Streams;
 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;
@@ -30,57 +30,48 @@
 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.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.server.group.InternalGroup;
-import com.google.gerrit.testing.GerritBaseTests;
+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.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.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
+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.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
-public class GroupRebuilderTest 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");
-
+public class GroupRebuilderTest extends AbstractGroupTest {
   private AtomicInteger idCounter;
   private Repository repo;
   private GroupRebuilder rebuilder;
+  private GroupBundle.Factory bundleFactory;
 
   @Before
-  public void setUp() {
+  public void setUp() throws Exception {
     TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
     idCounter = new AtomicInteger();
-    repo = new InMemoryRepository(new DfsRepositoryDescription(AllUsersNameProvider.DEFAULT));
+    repo = repoManager.createRepository(allUsersName);
     rebuilder =
         new GroupRebuilder(
-            () -> new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ),
-            new AllUsersName(AllUsersNameProvider.DEFAULT),
+            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.
-            (id, ident) ->
-                new PersonIdent(
-                    "Account " + id, id + "@server-id", ident.getWhen(), ident.getTimeZone()),
-            id -> String.format("Account %s <%s@server-id>", id, id),
-            uuid -> "Group " + uuid);
+            AbstractGroupTest::newPersonIdent,
+            AbstractGroupTest::getAccountNameEmail,
+            AbstractGroupTest::getGroupName);
+    bundleFactory =
+        new GroupBundle.Factory(new AuditLogReader(SERVER_ID, repoManager, allUsersName));
   }
 
   @After
@@ -93,12 +84,13 @@
     AccountGroup g = newGroup("a");
     GroupBundle b = builder().group(g).build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    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
@@ -109,9 +101,9 @@
     g.setVisibleToAll(true);
     GroupBundle b = builder().group(g).build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    assertThat(reload(g)).isEqualTo(b);
     ImmutableList<CommitInfo> log = log(g);
     assertThat(log).hasSize(1);
     assertServerCommit(log.get(0), "Create group");
@@ -127,9 +119,9 @@
             .byId(byId(g, "x"), byId(g, "y"))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    assertThat(reload(g)).isEqualTo(b);
     ImmutableList<CommitInfo> log = log(g);
     assertThat(log).hasSize(2);
     assertServerCommit(log.get(0), "Create group");
@@ -139,8 +131,8 @@
             + "\n"
             + "Add: Account 1 <1@server-id>\n"
             + "Add: Account 2 <2@server-id>\n"
-            + "Add-group: Group x\n"
-            + "Add-group: Group y");
+            + "Add-group: Group <x>\n"
+            + "Add-group: Group <y>");
   }
 
   @Test
@@ -156,9 +148,9 @@
             .memberAudit(addMember(g, 1, 8, t2), addAndRemoveMember(g, 2, 8, t1, 9, t3))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    assertThat(reload(g)).isEqualTo(b);
     ImmutableList<CommitInfo> log = log(g);
     assertThat(log).hasSize(4);
     assertServerCommit(log.get(0), "Create group");
@@ -182,9 +174,9 @@
                 addMember(g, 2, 8, TimeUtil.nowTs()))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    assertThat(reload(g)).isEqualTo(b);
     ImmutableList<CommitInfo> log = log(g);
     assertThat(log).hasSize(4);
     assertServerCommit(log.get(0), "Create group");
@@ -206,9 +198,9 @@
             .memberAudit(addMember(g, 1, 8, TimeUtil.nowTs()))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    assertThat(reload(g)).isEqualTo(b);
     ImmutableList<CommitInfo> log = log(g);
     assertThat(log).hasSize(3);
     assertServerCommit(log.get(0), "Create group");
@@ -231,15 +223,15 @@
             .byIdAudit(addById(g, "x", 8, t2), addAndRemoveById(g, "y", 8, t1, 9, t3))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    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");
+    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
@@ -252,14 +244,14 @@
             .byIdAudit(addById(g, "x", 8, TimeUtil.nowTs()))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    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");
+    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
@@ -282,9 +274,9 @@
                 addAndRemoveById(g, "z", user, ts, user, ts))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    assertThat(reload(g)).isEqualTo(b);
     ImmutableList<CommitInfo> log = log(g);
     assertThat(log).hasSize(5);
     assertServerCommit(log.get(0), "Create group");
@@ -303,12 +295,12 @@
         log.get(3),
         "Update group\n"
             + "\n"
-            + "Add-group: Group x\n"
-            + "Add-group: Group y\n"
-            + "Add-group: Group z",
+            + "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");
+    assertCommit(log.get(4), "Update group\n\nRemove-group: Group <z>", "Account 8", "8@server-id");
   }
 
   @Test
@@ -329,9 +321,9 @@
                 addById(g, "x", user1, ts), addById(g, "y", user2, ts), addById(g, "z", user1, ts))
             .build();
 
-    rebuilder.rebuild(repo, b);
+    rebuilder.rebuild(repo, b, null);
 
-    assertThat(reload(g)).isEqualTo(b.toInternalGroup());
+    assertThat(reload(g)).isEqualTo(b);
     ImmutableList<CommitInfo> log = log(g);
     assertThat(log).hasSize(5);
     assertServerCommit(log.get(0), "Create group");
@@ -342,16 +334,79 @@
         "8@server-id");
     assertCommit(
         log.get(2),
-        "Update group\n\nAdd-group: Group x\nAdd-group: Group z",
+        "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");
+    assertCommit(log.get(4), "Update group\n\nAdd-group: Group <y>", "Account 9", "9@server-id");
   }
 
-  private InternalGroup reload(AccountGroup g) throws Exception {
-    return GroupConfig.loadForGroup(repo, g.getGroupUUID()).getLoadedGroup().get();
+  @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) {
@@ -414,33 +469,14 @@
   }
 
   private ImmutableList<CommitInfo> log(AccountGroup g) throws Exception {
-    ImmutableList<CommitInfo> result = ImmutableList.of();
-    try (RevWalk rw = new RevWalk(repo)) {
-      Ref ref = repo.exactRef(RefNames.refsGroups(g.getGroupUUID()));
-      if (ref != null) {
-        rw.sort(RevSort.REVERSE);
-        rw.setRetainBody(true);
-        rw.markStart(rw.parseCommit(ref.getObjectId()));
-        result = Streams.stream(rw).map(CommitUtil::toCommitInfo).collect(toImmutableList());
-      }
-    }
-    return result;
+    return GroupTestUtil.log(repo, RefNames.refsGroups(g.getGroupUUID()));
   }
 
-  private static void assertServerCommit(CommitInfo commitInfo, String expectedMessage) {
-    assertCommit(commitInfo, expectedMessage, SERVER_NAME, SERVER_EMAIL);
+  private ImmutableList<CommitInfo> logGroupNames() throws Exception {
+    return GroupTestUtil.log(repo, REFS_GROUPNAMES);
   }
 
-  private 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);
+  private static InternalGroup removeRefState(InternalGroup group) throws Exception {
+    return group.toBuilder().setRefState(null).build();
   }
 }
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 1083a36..d4ecb6d 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -30,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 {
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/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index c889fc6..61e62f7 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -638,7 +638,7 @@
   }
 
   /** Boiler plate code to check two byte arrays for equality */
-  private class ByteArrayWrapper {
+  private static class ByteArrayWrapper {
     private byte[] arr;
 
     private ByteArrayWrapper(byte[] arr) {
@@ -652,5 +652,10 @@
       }
       return Arrays.equals(arr, ((ByteArrayWrapper) other).arr);
     }
+
+    @Override
+    public int hashCode() {
+      return Arrays.hashCode(arr);
+    }
   }
 }
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 25bb545..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,
+            };
           });
     },
 
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 bad47b9..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();
           });
     });
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 883a802..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
@@ -77,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 0f14088..b54824b 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 4decadb..ce7fe80 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
@@ -166,7 +166,7 @@
     });
 
     test('_formatBooleanSelect', () => {
-      let item = {inherited_value: 'true'};
+      let item = {inherited_value: true};
       assert.deepEqual(element._formatBooleanSelect(item), [
         {
           label: 'Inherit (true)',
@@ -181,6 +181,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 eff4345..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
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../styles/shared-styles.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">
@@ -329,6 +330,8 @@
         </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>
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 4df15c1..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)]]">
@@ -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..145c1f8 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,23 +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..ccdc50e 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'}); },
@@ -120,14 +125,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 +845,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 +874,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 +974,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 +1025,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 +1176,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 +1378,30 @@
       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');
+        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-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 da3a547..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;
@@ -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 e0b4d6c..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
@@ -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-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.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..6a41652 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,10 +508,10 @@
     },
 
     _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) {
+      } else if (section === FocusTarget.BODY) {
         const textarea = this.$.textarea;
         textarea.async(textarea.getNativeTextarea()
             .focus.bind(textarea.getNativeTextarea()));
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..9c19267 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.stub(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, 0);
+      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, 0);
+      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, 1);
+      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, 1);
+      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, 1);
+      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-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 9322782..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
@@ -300,12 +304,11 @@
   */
   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
+    // 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 Gerrit.PatchSetBehavior.isMergeParent(range.basePatchNum) &&
-          comment.parent ===
-              Gerrit.PatchSetBehavior.getParentIndex(range.basePatchNum);
+      return this._isMergeParent(range.basePatchNum) &&
+          comment.parent === this._getParentIndex(range.basePatchNum);
     }
 
     // If the base of the range is the parent of the patch:
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-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/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 5dbd466..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
@@ -48,7 +48,7 @@
         <span class="title">Username</span>
         <span
             hidden$="[[usernameMutable]]"
-            class="value">[[_account.username]]</span>
+            class="value">[[_username]]</span>
         <span
             hidden$="[[!usernameMutable]]"
             class="value">
@@ -57,7 +57,7 @@
               id="usernameInput"
               disabled="[[_saving]]"
               on-keydown="_handleKeydown"
-              bind-value="{{_account.username}}">
+              bind-value="{{_username}}">
       </section>
       <section id="nameSection">
         <span class="title">Full name</span>
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 03795f6..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
@@ -27,7 +27,7 @@
       usernameMutable: {
         type: Boolean,
         notify: true,
-        computed: '_computeUsernameMutable(_serverConfig)',
+        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
       },
       nameMutable: {
         type: Boolean,
@@ -64,11 +64,14 @@
       /** @type {?} */
       _account: Object,
       _serverConfig: Object,
+      _username: {
+        type: String,
+        observer: '_usernameChanged',
+      },
     },
 
     observers: [
       '_nameChanged(_account.name)',
-      '_usernameChanged(_account.username)',
       '_statusChanged(_account.status)',
     ],
 
@@ -82,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(() => {
@@ -117,7 +124,7 @@
 
     _maybeSetUsername() {
       return this._hasUsernameChange && this.usernameMutable ?
-          this.$.restAPI.setAccountUsername(this._account.username) :
+          this.$.restAPI.setAccountUsername(this._username) :
           Promise.resolve();
     },
 
@@ -131,8 +138,10 @@
       return nameChanged || usernameChanged || statusChanged;
     },
 
-    _computeUsernameMutable(config) {
-      return config.auth.editable_account_fields.includes('USER_NAME');
+    _computeUsernameMutable(config, username) {
+      // Username may not be changed once it is set.
+      return config.auth.editable_account_fields.includes('USER_NAME') &&
+          !username;
     },
 
     _computeNameMutable(config) {
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 e46be5b..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
@@ -123,6 +123,8 @@
     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];
@@ -179,10 +181,11 @@
       });
 
       test('username', done => {
+        element.set('_account.username', '');
+        element._hasUsernameChange = false;
         assert.isTrue(element.usernameMutable);
-        assert.isFalse(element.hasUnsavedChanges);
 
-        element.set('_account.username', 'new username');
+        element.set('_username', 'new username');
 
         assert.isTrue(usernameChangedSpy.called);
         assert.isFalse(statusChangedSpy.called);
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/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 320c908..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,8 +55,8 @@
       }
     </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">[[cancelLabel]]</gr-button>
         <gr-button link primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
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.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index e411274..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
@@ -40,6 +40,11 @@
     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',
 
@@ -840,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
       );
@@ -1636,6 +1642,7 @@
     },
 
     _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
+      const isCreate = !draft.id && method === 'PUT';
       let endpoint = '/drafts';
       if (draft.id) {
         endpoint += '/' + draft.id;
@@ -1652,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;
     },
 
@@ -1996,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 4990a9a..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');
+          });
+        });
       });
     });
 
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/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