Merge "Fix 'press c to comment' on first line of diff"
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/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 45c6e72..8ae25fd 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,6 +18,23 @@
 });
 ```
 
+== 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
@@ -30,3 +45,10 @@
 `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]
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index e059671..732c1f7 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -23,7 +23,6 @@
         "//java/com/google/gerrit/pgm/util",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/diff",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/sshd",
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/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index b7ec467..b5995a8 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -71,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;
@@ -307,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);
   }
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/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/BUILD b/java/com/google/gerrit/server/BUILD
index 3840e64..6052a63 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -38,7 +38,6 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/prettify:server",
         "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gerrit/server/diff",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/util/cli",
         "//java/com/google/gerrit/util/ssl",
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 62eab91..1c71c70 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -44,13 +44,13 @@
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.update.BatchUpdateReviewDb;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/change/ChangeEdits.java b/java/com/google/gerrit/server/change/ChangeEdits.java
index 7681101..1ca98b7 100644
--- a/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -40,13 +40,13 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.WebLinks;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditJson;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.edit.UnchangedCommitMessageException;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 74553aa..a03f60a 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -109,8 +109,6 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GpgApiAdapter;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.diff.DiffSummary.ChangedLines;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -118,6 +116,7 @@
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -128,6 +127,7 @@
 import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/change/CreateDraftComment.java b/java/com/google/gerrit/server/change/CreateDraftComment.java
index b41b323..002c8b7 100644
--- a/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.diff.PatchListCache;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
diff --git a/java/com/google/gerrit/server/change/DeleteDraftComment.java b/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 6e901ba..6d82139 100644
--- a/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.diff.PatchListCache;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index 4285319..6ccd460 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -21,11 +21,11 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Map;
diff --git a/java/com/google/gerrit/server/change/Files.java b/java/com/google/gerrit/server/change/Files.java
index cd92d71..c167e31 100644
--- a/java/com/google/gerrit/server/change/Files.java
+++ b/java/com/google/gerrit/server/change/Files.java
@@ -37,11 +37,11 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.AccountPatchReviewStore.PatchSetWithReviewedFiles;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/change/GetContent.java b/java/com/google/gerrit/server/change/GetContent.java
index cb921f6..f6b24b8 100644
--- a/java/com/google/gerrit/server/change/GetContent.java
+++ b/java/com/google/gerrit/server/change/GetContent.java
@@ -23,10 +23,10 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.diff.ComparisonType;
-import com.google.gerrit.server.diff.Text;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/change/GetMergeList.java b/java/com/google/gerrit/server/change/GetMergeList.java
index 20c9ea4..88677d6 100644
--- a/java/com/google/gerrit/server/change/GetMergeList.java
+++ b/java/com/google/gerrit/server/change/GetMergeList.java
@@ -21,8 +21,8 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.diff.MergeListBuilder;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.MergeListBuilder;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/change/PostReview.java b/java/com/google/gerrit/server/change/PostReview.java
index 5467a62..0022656 100644
--- a/java/com/google/gerrit/server/change/PostReview.java
+++ b/java/com/google/gerrit/server/change/PostReview.java
@@ -88,16 +88,16 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.diff.DiffSummary;
-import com.google.gerrit.server.diff.DiffSummaryKey;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
diff --git a/java/com/google/gerrit/server/change/PutDraftComment.java b/java/com/google/gerrit/server/change/PutDraftComment.java
index eadd7b6..c5693c6 100644
--- a/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -30,8 +30,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
diff --git a/java/com/google/gerrit/server/diff/BUILD b/java/com/google/gerrit/server/diff/BUILD
deleted file mode 100644
index 606d7bd..0000000
--- a/java/com/google/gerrit/server/diff/BUILD
+++ /dev/null
@@ -1,18 +0,0 @@
-java_library(
-    name = "diff",
-    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/reviewdb:server",
-        "//java/com/google/gerrit/server/ioutil",
-        "//java/org/eclipse/jgit:server",
-        "//lib:guava",
-        "//lib:juniversalchardet",
-        "//lib/auto:auto-value",
-        "//lib/jgit/org.eclipse.jgit:jgit",
-        "//lib/log:api",
-    ],
-)
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 584a5e9..2614eaf 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -51,11 +51,11 @@
 import com.google.gerrit.server.data.SubmitLabelAttribute;
 import com.google.gerrit.server.data.SubmitRecordAttribute;
 import com.google.gerrit.server.data.TrackingIdAttribute;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index 92e0fc1..ef69616 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 943913d..e9ae356 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index afbab0d..c25deab 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index af0ee5e..77cd1a8 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 8c217bc..95d7132 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index 3b5c66c..fc6881d 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index e156865..28e07a9 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 6b384a0..76779ca 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 070d5b8..8944698 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -25,7 +25,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
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/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/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 17ab344..c23dc09 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -44,12 +44,19 @@
 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 {
   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");
@@ -332,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);
   }
 
@@ -347,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/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 3765847..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;
@@ -104,8 +106,8 @@
   @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
@@ -121,6 +123,7 @@
       @GerritServerId String serverId,
       @GerritPersonIdent PersonIdent serverIdent,
       MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GroupsMigration groupsMigration,
       @GerritServerConfig Config config,
       GitReferenceUpdated gitRefUpdated,
       @Assisted @Nullable IdentifiedUser currentUser) {
@@ -133,17 +136,13 @@
     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);
   }
 
@@ -206,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;
     }
@@ -242,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;
     }
 
diff --git a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
index 7378b15..a71f417 100644
--- a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
+++ b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
@@ -24,6 +24,7 @@
 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;
@@ -55,7 +56,17 @@
       if (ref != null) {
         rw.sort(RevSort.REVERSE);
         rw.markStart(rw.parseCommit(ref.getObjectId()));
-        return Streams.stream(rw).map(CommitUtil::toCommitInfo).collect(toImmutableList());
+        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();
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index 2d742c6..b0527e1 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -102,7 +102,7 @@
   }
 
   private static boolean autoReindexIfStale(Config cfg) {
-    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
+    return cfg.getBoolean("index", null, "autoReindexIfStale", false);
   }
 
   private void autoReindexIfStale(Account.Id id) {
diff --git a/java/com/google/gerrit/server/index/group/StalenessChecker.java b/java/com/google/gerrit/server/index/group/StalenessChecker.java
index 5c641ef..418bb35 100644
--- a/java/com/google/gerrit/server/index/group/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/group/StalenessChecker.java
@@ -20,13 +20,12 @@
 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.GerritServerConfig;
 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.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -48,7 +47,7 @@
   private final GitRepositoryManager repoManager;
   private final IndexConfig indexConfig;
   private final AllUsersName allUsers;
-  private final Config config;
+  private final GroupsMigration groupsMigration;
 
   @Inject
   StalenessChecker(
@@ -56,16 +55,16 @@
       GitRepositoryManager repoManager,
       IndexConfig indexConfig,
       AllUsersName allUsers,
-      @GerritServerConfig Config config) {
+      GroupsMigration groupsMigration) {
     this.indexes = indexes;
     this.repoManager = repoManager;
     this.indexConfig = indexConfig;
     this.allUsers = allUsers;
-    this.config = config;
+    this.groupsMigration = groupsMigration;
   }
 
   public boolean isStale(AccountGroup.UUID uuid) throws IOException {
-    if (!config.getBoolean("user", "readGroupsFromNoteDb", false)) {
+    if (!groupsMigration.readFromNoteDb()) {
       return false; // This class only treats staleness for groups in NoteDb.
     }
 
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 8a687fd..3e8734a 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -40,10 +40,10 @@
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 8a7c985..3e1dc92 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -31,11 +31,11 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.permissions.ChangePermission;
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 4cef569..8055273 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -30,11 +30,11 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.diff.PatchFile;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.mail.MailUtil;
 import com.google.gerrit.server.mail.receive.Protocol;
+import com.google.gerrit.server.patch.PatchFile;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.client.KeyUtil;
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index e6116c7..83a1c25 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -28,10 +28,10 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
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/notedb/rebuild/ChangeRebuilderImpl.java b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index eab4544..f96e96c 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -52,7 +52,6 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeDraftUpdate;
@@ -66,6 +65,7 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
diff --git a/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
index 97a94d1..c8a649e 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gwtorm.server.OrmException;
 
 class CommentEvent extends Event {
diff --git a/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
index 468d5b1..914930c 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
@@ -21,9 +21,9 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.notedb.ChangeDraftUpdate;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gwtorm.server.OrmException;
 
 class DraftCommentEvent extends Event {
diff --git a/java/com/google/gerrit/server/diff/CharText.java b/java/com/google/gerrit/server/patch/CharText.java
similarity index 80%
rename from java/com/google/gerrit/server/diff/CharText.java
rename to java/com/google/gerrit/server/patch/CharText.java
index 110dc44..2a0cd2f 100644
--- a/java/com/google/gerrit/server/diff/CharText.java
+++ b/java/com/google/gerrit/server/patch/CharText.java
@@ -12,26 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import org.eclipse.jgit.diff.Sequence;
 
-public class CharText extends Sequence {
+class CharText extends Sequence {
   private final String content;
 
-  public CharText(Text text, int s, int e) {
+  CharText(Text text, int s, int e) {
     content = text.getString(s, e, false /* keep LF */);
   }
 
-  public char charAt(int idx) {
+  char charAt(int idx) {
     return content.charAt(idx);
   }
 
-  public boolean isLineStart(int b) {
+  boolean isLineStart(int b) {
     return b == 0 || charAt(b - 1) == '\n';
   }
 
-  public boolean contains(int b, int e, char c) {
+  boolean contains(int b, int e, char c) {
     for (; b < e; b++) {
       if (charAt(b) == c) {
         return true;
diff --git a/java/com/google/gerrit/server/diff/CharTextComparator.java b/java/com/google/gerrit/server/patch/CharTextComparator.java
similarity index 88%
rename from java/com/google/gerrit/server/diff/CharTextComparator.java
rename to java/com/google/gerrit/server/patch/CharTextComparator.java
index e8c9588..8119313 100644
--- a/java/com/google/gerrit/server/diff/CharTextComparator.java
+++ b/java/com/google/gerrit/server/patch/CharTextComparator.java
@@ -12,11 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import org.eclipse.jgit.diff.SequenceComparator;
 
-public class CharTextComparator extends SequenceComparator<CharText> {
+class CharTextComparator extends SequenceComparator<CharText> {
   @Override
   public boolean equals(CharText a, int ai, CharText b, int bi) {
     return a.charAt(ai) == b.charAt(bi);
diff --git a/java/com/google/gerrit/server/diff/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
similarity index 97%
rename from java/com/google/gerrit/server/diff/ComparisonType.java
rename to java/com/google/gerrit/server/patch/ComparisonType.java
index f7bd276..abbb680 100644
--- a/java/com/google/gerrit/server/diff/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
diff --git a/java/com/google/gerrit/server/diff/DiffSummary.java b/java/com/google/gerrit/server/patch/DiffSummary.java
similarity index 89%
rename from java/com/google/gerrit/server/diff/DiffSummary.java
rename to java/com/google/gerrit/server/patch/DiffSummary.java
index 1e1807e4..877dba0 100644
--- a/java/com/google/gerrit/server/diff/DiffSummary.java
+++ b/java/com/google/gerrit/server/patch/DiffSummary.java
@@ -12,13 +12,14 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readString;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
 
+import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import java.io.IOException;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
@@ -71,14 +72,4 @@
       }
     }
   }
-
-  public static class ChangedLines {
-    public final int insertions;
-    public final int deletions;
-
-    public ChangedLines(int insertions, int deletions) {
-      this.insertions = insertions;
-      this.deletions = deletions;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/diff/DiffSummaryKey.java b/java/com/google/gerrit/server/patch/DiffSummaryKey.java
similarity index 97%
rename from java/com/google/gerrit/server/diff/DiffSummaryKey.java
rename to java/com/google/gerrit/server/patch/DiffSummaryKey.java
index 3e98fe2..0a02e36 100644
--- a/java/com/google/gerrit/server/diff/DiffSummaryKey.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryKey.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static org.eclipse.jgit.lib.ObjectIdSerialization.readCanBeNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
@@ -53,7 +53,7 @@
     this.whitespace = whitespace;
   }
 
-  public PatchListKey toPatchListKey() {
+  PatchListKey toPatchListKey() {
     return new PatchListKey(
         oldId, parentNum, newId, whitespace, PatchListKey.Algorithm.OPTIMIZED_DIFF);
   }
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index 4df1f38..188513f 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -16,11 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.diff.DiffSummary;
-import com.google.gerrit.server.diff.DiffSummaryKey;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java b/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
index 863cda8..98e17a5 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryWeigher.java
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.patch;
 
 import com.google.common.cache.Weigher;
-import com.google.gerrit.server.diff.DiffSummary;
-import com.google.gerrit.server.diff.DiffSummaryKey;
 
 /** Computes memory usage for {@link DiffSummary} in bytes of memory used. */
 public class DiffSummaryWeigher implements Weigher<DiffSummaryKey, DiffSummary> {
diff --git a/java/com/google/gerrit/server/patch/EditTransformer.java b/java/com/google/gerrit/server/patch/EditTransformer.java
index 5e35bfa..271c7c3 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/EditTransformer.java
@@ -25,7 +25,6 @@
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Multimap;
-import com.google.gerrit.server.diff.PatchListEntry;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -183,7 +182,7 @@
   }
 
   @AutoValue
-  public abstract static class ContextAwareEdit {
+  abstract static class ContextAwareEdit {
     static ContextAwareEdit create(PatchListEntry patchListEntry, Edit edit) {
       return create(
           patchListEntry.getOldName(),
diff --git a/java/com/google/gerrit/server/diff/IntraLineDiff.java b/java/com/google/gerrit/server/patch/IntraLineDiff.java
similarity index 97%
rename from java/com/google/gerrit/server/diff/IntraLineDiff.java
rename to java/com/google/gerrit/server/patch/IntraLineDiff.java
index 15edbb3..ee8b88b 100644
--- a/java/com/google/gerrit/server/diff/IntraLineDiff.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiff.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readEnum;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
@@ -58,12 +58,12 @@
   private transient Status status;
   private transient ImmutableList<Edit> edits;
 
-  public IntraLineDiff(Status status) {
+  IntraLineDiff(Status status) {
     this.status = status;
     this.edits = ImmutableList.of();
   }
 
-  public IntraLineDiff(List<Edit> edits) {
+  IntraLineDiff(List<Edit> edits) {
     this.status = Status.EDIT_LIST;
     this.edits = ImmutableList.copyOf(edits);
   }
diff --git a/java/com/google/gerrit/server/diff/IntraLineDiffArgs.java b/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
similarity index 97%
rename from java/com/google/gerrit/server/diff/IntraLineDiffArgs.java
rename to java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
index 26bbe6c..882360c 100644
--- a/java/com/google/gerrit/server/diff/IntraLineDiffArgs.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
diff --git a/java/com/google/gerrit/server/diff/IntraLineDiffKey.java b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
similarity index 96%
rename from java/com/google/gerrit/server/diff/IntraLineDiffKey.java
rename to java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index 0e8c4ac..fbda00a 100644
--- a/java/com/google/gerrit/server/diff/IntraLineDiffKey.java
+++ b/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import com.google.auto.value.AutoValue;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
diff --git a/java/com/google/gerrit/server/patch/IntraLineLoader.java b/java/com/google/gerrit/server/patch/IntraLineLoader.java
index 002d819..e5e1bad 100644
--- a/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -19,12 +19,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.diff.CharText;
-import com.google.gerrit.server.diff.CharTextComparator;
-import com.google.gerrit.server.diff.IntraLineDiff;
-import com.google.gerrit.server.diff.IntraLineDiffArgs;
-import com.google.gerrit.server.diff.IntraLineDiffKey;
-import com.google.gerrit.server.diff.Text;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
diff --git a/java/com/google/gerrit/server/patch/IntraLineWeigher.java b/java/com/google/gerrit/server/patch/IntraLineWeigher.java
index 62e5f35..7bd37af 100644
--- a/java/com/google/gerrit/server/patch/IntraLineWeigher.java
+++ b/java/com/google/gerrit/server/patch/IntraLineWeigher.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2012 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.
@@ -15,8 +15,6 @@
 package com.google.gerrit.server.patch;
 
 import com.google.common.cache.Weigher;
-import com.google.gerrit.server.diff.IntraLineDiff;
-import com.google.gerrit.server.diff.IntraLineDiffKey;
 
 /** Approximates memory usage for IntralineDiff in bytes of memory used. */
 public class IntraLineWeigher implements Weigher<IntraLineDiffKey, IntraLineDiff> {
diff --git a/java/com/google/gerrit/server/diff/MergeListBuilder.java b/java/com/google/gerrit/server/patch/MergeListBuilder.java
similarity index 97%
rename from java/com/google/gerrit/server/diff/MergeListBuilder.java
rename to java/com/google/gerrit/server/patch/MergeListBuilder.java
index fdfe4b8..433fcad 100644
--- a/java/com/google/gerrit/server/diff/MergeListBuilder.java
+++ b/java/com/google/gerrit/server/patch/MergeListBuilder.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import com.google.common.collect.ImmutableList;
 import java.io.IOException;
diff --git a/java/com/google/gerrit/server/diff/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
similarity index 98%
rename from java/com/google/gerrit/server/diff/PatchFile.java
rename to java/com/google/gerrit/server/patch/PatchFile.java
index 0b50ddd..aff519a 100644
--- a/java/com/google/gerrit/server/diff/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
diff --git a/java/com/google/gerrit/server/diff/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
similarity index 99%
rename from java/com/google/gerrit/server/diff/PatchList.java
rename to java/com/google/gerrit/server/patch/PatchList.java
index b1b1715..16ede58 100644
--- a/java/com/google/gerrit/server/diff/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readBytes;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
diff --git a/java/com/google/gerrit/server/diff/PatchListCache.java b/java/com/google/gerrit/server/patch/PatchListCache.java
similarity index 96%
rename from java/com/google/gerrit/server/diff/PatchListCache.java
rename to java/com/google/gerrit/server/patch/PatchListCache.java
index 6cff23c..728d227 100644
--- a/java/com/google/gerrit/server/diff/PatchListCache.java
+++ b/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index e61b08e..8900a15 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -26,18 +26,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.diff.ComparisonType;
-import com.google.gerrit.server.diff.DiffSummary;
-import com.google.gerrit.server.diff.DiffSummaryKey;
-import com.google.gerrit.server.diff.IntraLineDiff;
-import com.google.gerrit.server.diff.IntraLineDiffArgs;
-import com.google.gerrit.server.diff.IntraLineDiffKey;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
-import com.google.gerrit.server.diff.PatchListWeigher;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
diff --git a/java/com/google/gerrit/server/diff/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java
similarity index 96%
rename from java/com/google/gerrit/server/diff/PatchListEntry.java
rename to java/com/google/gerrit/server/patch/PatchListEntry.java
index fb94c70..96f66f6 100644
--- a/java/com/google/gerrit/server/diff/PatchListEntry.java
+++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static com.google.gerrit.server.ioutil.BasicSerialization.readBytes;
 import static com.google.gerrit.server.ioutil.BasicSerialization.readEnum;
@@ -48,7 +48,7 @@
 public class PatchListEntry {
   private static final byte[] EMPTY_HEADER = {};
 
-  public static PatchListEntry empty(String fileName) {
+  static PatchListEntry empty(String fileName) {
     return new PatchListEntry(
         ChangeType.MODIFIED,
         PatchType.UNIFIED,
@@ -77,7 +77,7 @@
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  public PatchListEntry(
+  PatchListEntry(
       FileHeader hdr, List<Edit> editList, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
@@ -153,7 +153,7 @@
     this.sizeDelta = sizeDelta;
   }
 
-  public int weigh() {
+  int weigh() {
     int size = 16 + 6 * 8 + 2 * 4 + 20 + 16 + 8 + 4 + 20;
     size += stringSize(oldName);
     size += stringSize(newName);
@@ -324,15 +324,15 @@
   private static ChangeType toChangeType(FileHeader hdr) {
     switch (hdr.getChangeType()) {
       case ADD:
-        return ChangeType.ADDED;
+        return Patch.ChangeType.ADDED;
       case MODIFY:
-        return ChangeType.MODIFIED;
+        return Patch.ChangeType.MODIFIED;
       case DELETE:
-        return ChangeType.DELETED;
+        return Patch.ChangeType.DELETED;
       case RENAME:
-        return ChangeType.RENAMED;
+        return Patch.ChangeType.RENAMED;
       case COPY:
-        return ChangeType.COPIED;
+        return Patch.ChangeType.COPIED;
       default:
         throw new IllegalArgumentException("Unsupported type " + hdr.getChangeType());
     }
@@ -343,11 +343,11 @@
 
     switch (hdr.getPatchType()) {
       case UNIFIED:
-        pt = PatchType.UNIFIED;
+        pt = Patch.PatchType.UNIFIED;
         break;
       case GIT_BINARY:
       case BINARY:
-        pt = PatchType.BINARY;
+        pt = Patch.PatchType.BINARY;
         break;
       default:
         throw new IllegalArgumentException("Unsupported type " + hdr.getPatchType());
diff --git a/java/com/google/gerrit/server/diff/PatchListKey.java b/java/com/google/gerrit/server/patch/PatchListKey.java
similarity index 99%
rename from java/com/google/gerrit/server/diff/PatchListKey.java
rename to java/com/google/gerrit/server/patch/PatchListKey.java
index 357e1af..73e82a1 100644
--- a/java/com/google/gerrit/server/diff/PatchListKey.java
+++ b/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.readCanBeNull;
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index 199503d..bf6e345 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -32,13 +32,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.diff.ComparisonType;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
-import com.google.gerrit.server.diff.Text;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
@@ -517,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/diff/PatchListNotAvailableException.java b/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
similarity index 95%
rename from java/com/google/gerrit/server/diff/PatchListNotAvailableException.java
rename to java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
index 0f5981e..fab66cb 100644
--- a/java/com/google/gerrit/server/diff/PatchListNotAvailableException.java
+++ b/java/com/google/gerrit/server/patch/PatchListNotAvailableException.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 public class PatchListNotAvailableException extends Exception {
   private static final long serialVersionUID = 1L;
diff --git a/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java b/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
index bcb1c34..54e0e6c 100644
--- a/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
+++ b/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.patch;
 
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
-
 /**
  * Exception thrown when the PatchList could not be computed because previous attempts failed with
  * {@code LargeObjectException}. This is not thrown on the first computation.
diff --git a/java/com/google/gerrit/server/diff/PatchListWeigher.java b/java/com/google/gerrit/server/patch/PatchListWeigher.java
similarity index 96%
rename from java/com/google/gerrit/server/diff/PatchListWeigher.java
rename to java/com/google/gerrit/server/patch/PatchListWeigher.java
index 74b9c9b..942d0e0 100644
--- a/java/com/google/gerrit/server/diff/PatchListWeigher.java
+++ b/java/com/google/gerrit/server/patch/PatchListWeigher.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import com.google.common.cache.Weigher;
 
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 5af2f6d..8b86be2 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -27,13 +27,6 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.diff.ComparisonType;
-import com.google.gerrit.server.diff.IntraLineDiff;
-import com.google.gerrit.server.diff.IntraLineDiffArgs;
-import com.google.gerrit.server.diff.IntraLineDiffKey;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.Text;
 import com.google.gerrit.server.mime.FileTypeRegistry;
 import com.google.inject.Inject;
 import eu.medsea.mimeutil.MimeType;
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 02aff82..fe158f8 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -32,11 +32,6 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
diff --git a/java/com/google/gerrit/server/diff/Text.java b/java/com/google/gerrit/server/patch/Text.java
similarity index 99%
rename from java/com/google/gerrit/server/diff/Text.java
rename to java/com/google/gerrit/server/patch/Text.java
index 40b8db8..90141715 100644
--- a/java/com/google/gerrit/server/diff/Text.java
+++ b/java/com/google/gerrit/server/patch/Text.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
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/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index a405678..dfcc999 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -63,16 +63,15 @@
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.diff.DiffSummary;
-import com.google.gerrit.server.diff.DiffSummary.ChangedLines;
-import com.google.gerrit.server.diff.DiffSummaryKey;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -1195,6 +1194,16 @@
     return h.toString();
   }
 
+  public static class ChangedLines {
+    public final int insertions;
+    public final int deletions;
+
+    public ChangedLines(int insertions, int deletions) {
+      this.insertions = insertions;
+      this.deletions = deletions;
+    }
+  }
+
   public ImmutableList<byte[]> getRefStates() {
     return refStates;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 80c672e..b00345f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -57,7 +57,6 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.strategy.SubmitDryRun;
 import com.google.gerrit.server.group.ListMembers;
@@ -69,6 +68,7 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 1f93281..170ff23 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.diff.PatchListCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 3cb322c..287845d 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -29,11 +29,11 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.Emails;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.PatchListNotAvailableException;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackend;
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 d62bde7..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;
@@ -72,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;
@@ -89,6 +90,7 @@
       @GerritPersonIdent PersonIdent au,
       DataSourceType dst,
       GroupIndexCollection ic,
+      GroupsMigration gm,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
       NotesMigration migration,
@@ -102,6 +104,7 @@
         au,
         dst,
         ic,
+        gm,
         config,
         metricMaker,
         migration,
@@ -117,6 +120,7 @@
       @GerritPersonIdent PersonIdent au,
       DataSourceType dst,
       GroupIndexCollection ic,
+      GroupsMigration gm,
       Config config,
       MetricMaker metricMaker,
       NotesMigration migration,
@@ -129,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;
@@ -213,7 +213,7 @@
       throws OrmException, ConfigInvalidException, IOException {
     InternalGroup groupInReviewDb = createGroupInReviewDb(db, groupCreation, groupUpdate);
 
-    if (!writeGroupsToNoteDb) {
+    if (!groupsMigration.writeToNoteDb()) {
       index(groupInReviewDb);
       return;
     }
diff --git a/java/com/google/gerrit/server/schema/Schema_115.java b/java/com/google/gerrit/server/schema/Schema_115.java
index 7be595d..3c6a50e 100644
--- a/java/com/google/gerrit/server/schema/Schema_115.java
+++ b/java/com/google/gerrit/server/schema/Schema_115.java
@@ -26,11 +26,11 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.VersionedAccountPreferences;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.diff.PatchListKey;
 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.UserConfigSections;
+import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
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/gerrit/BUILD b/java/gerrit/BUILD
index ccb159b..980ad23 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -7,7 +7,6 @@
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/diff",
         "//lib:gwtorm",
         "//lib/jgit/org.eclipse.jgit:jgit",
         "//lib/log:api",
diff --git a/java/gerrit/PRED_commit_delta_4.java b/java/gerrit/PRED_commit_delta_4.java
index 73602fd..7c26632 100644
--- a/java/gerrit/PRED_commit_delta_4.java
+++ b/java/gerrit/PRED_commit_delta_4.java
@@ -15,8 +15,8 @@
 package gerrit;
 
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListEntry;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index 3b5c177..c196026 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -14,9 +14,9 @@
 
 package gerrit;
 
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.Text;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
diff --git a/java/gerrit/PRED_commit_stats_3.java b/java/gerrit/PRED_commit_stats_3.java
index b290ba4..c1666d8 100644
--- a/java/gerrit/PRED_commit_stats_3.java
+++ b/java/gerrit/PRED_commit_stats_3.java
@@ -15,8 +15,8 @@
 package gerrit;
 
 import com.google.gerrit.reviewdb.client.Patch;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListEntry;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupRebuilderIT.java
index bea41c3..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() {
@@ -88,10 +85,11 @@
   public void basicGroupProperties() throws Exception {
     GroupInfo createdGroup = gApi.groups().create(name("group")).get();
     try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
-      InternalGroup reviewDbGroup =
-          groups.getGroup(db, new AccountGroup.UUID(createdGroup.id)).get();
-      deleteGroupRefs(reviewDbGroup);
-      assertThat(removeRefState(rebuild(reviewDbGroup))).isEqualTo(roundToSecond(reviewDbGroup));
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(createdGroup.groupId));
+      deleteGroupRefs(reviewDbBundle);
+
+      assertThat(rebuild(reviewDbBundle)).isEqualTo(reviewDbBundle.roundToSecond());
     }
   }
 
@@ -108,11 +106,12 @@
     gApi.groups().id(group1.id).addGroups(group2.id);
 
     try (BlockReviewDbUpdatesForGroups ctx = new BlockReviewDbUpdatesForGroups()) {
-      InternalGroup reviewDbGroup = groups.getGroup(db, new AccountGroup.UUID(group1.id)).get();
-      deleteGroupRefs(reviewDbGroup);
+      GroupBundle reviewDbBundle =
+          bundleFactory.fromReviewDb(db, new AccountGroup.Id(group1.groupId));
+      deleteGroupRefs(reviewDbBundle);
 
-      InternalGroup noteDbGroup = rebuild(reviewDbGroup);
-      assertThat(removeRefState(noteDbGroup)).isEqualTo(roundToSecond(reviewDbGroup));
+      GroupBundle noteDbBundle = rebuild(reviewDbBundle);
+      assertThat(noteDbBundle).isEqualTo(reviewDbBundle.roundToSecond());
 
       ImmutableList<CommitInfo> log = log(group1);
       assertThat(log).hasSize(4);
@@ -120,7 +119,7 @@
       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().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);
 
@@ -151,13 +150,9 @@
     }
   }
 
-  private static InternalGroup removeRefState(InternalGroup group) throws Exception {
-    return group.toBuilder().setRefState(null).build();
-  }
-
-  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);
@@ -170,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()), null);
-      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<>();
@@ -225,7 +203,7 @@
     }
 
     private void blockReviewDbUpdates(boolean block) {
-      cfg.setBoolean("user", null, "readGroupsFromNoteDb", 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 b2c4056..ba92835 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -22,6 +22,10 @@
 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;
@@ -110,8 +114,8 @@
   @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;
   }
 
@@ -774,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);
@@ -802,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;
     }
@@ -1236,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 11c98c7..0adfafd 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -508,7 +508,7 @@
 
   @Test
   @Sandboxed
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void advertisedReferencesDontShowGroupBranchToOwnerWithoutRead() throws Exception {
     createSelfOwnedGroup("Foos", user);
     TestRepository<?> userTestRepository = cloneProject(allUsers, user);
@@ -519,7 +519,7 @@
 
   @Test
   @Sandboxed
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @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);
@@ -534,7 +534,7 @@
 
   @Test
   @Sandboxed
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void advertisedReferencesIncludeAllGroupBranchesWithAccessDatabase() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     AccountGroup.UUID users = createGroup("Users", admins);
@@ -549,7 +549,7 @@
   }
 
   @Test
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @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);
@@ -569,7 +569,7 @@
   }
 
   @Test
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @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);
@@ -649,7 +649,7 @@
   }
 
   @Test
-  @GerritConfig(name = "user.writeGroupsToNoteDb", value = "true")
+  @GerritConfig(name = "noteDb.groups.write", value = "true")
   public void hideMetadata() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
     try {
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/server/change/PatchListCacheIT.java b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
index 112d0b2..4f61a56 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
@@ -27,15 +27,15 @@
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
-import com.google.gerrit.server.diff.IntraLineDiff;
-import com.google.gerrit.server.diff.IntraLineDiffArgs;
-import com.google.gerrit.server.diff.IntraLineDiffKey;
-import com.google.gerrit.server.diff.PatchList;
-import com.google.gerrit.server.diff.PatchListCache;
-import com.google.gerrit.server.diff.PatchListEntry;
-import com.google.gerrit.server.diff.PatchListKey;
-import com.google.gerrit.server.diff.Text;
+import com.google.gerrit.server.patch.IntraLineDiff;
+import com.google.gerrit.server.patch.IntraLineDiffArgs;
+import com.google.gerrit.server.patch.IntraLineDiffKey;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.Text;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
 import java.util.ArrayList;
diff --git a/javatests/com/google/gerrit/acceptance/tests.bzl b/javatests/com/google/gerrit/acceptance/tests.bzl
index 1b646d2..4b3b802d 100644
--- a/javatests/com/google/gerrit/acceptance/tests.bzl
+++ b/javatests/com/google/gerrit/acceptance/tests.bzl
@@ -9,10 +9,7 @@
   junit_tests(
     name = group,
     deps = deps + [
-        # j/c/g/gerrit/acceptance:lib exports all dependencies that
-        # acceptance tests need. Additional dependencies should go
-        # there.
-        '//java/com/google/gerrit/acceptance:lib',
+      '//java/com/google/gerrit/acceptance:lib',
     ],
     tags = labels + [
       'acceptance',
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 83186ff..2eaf4f5 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -41,7 +41,6 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/reviewdb:server",
         "//java/com/google/gerrit/server",
-        "//java/com/google/gerrit/server/diff",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
         "//java/com/google/gerrit/testing:gerrit-test-util",
         "//java/org/eclipse/jgit:server",
diff --git a/javatests/com/google/gerrit/server/diff/BUILD b/javatests/com/google/gerrit/server/diff/BUILD
deleted file mode 100644
index a136c49..0000000
--- a/javatests/com/google/gerrit/server/diff/BUILD
+++ /dev/null
@@ -1,15 +0,0 @@
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-junit_tests(
-    name = "diff_tests",
-    srcs = glob(["**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/reviewdb:server",
-        "//java/com/google/gerrit/server/diff",
-        "//java/org/eclipse/jgit:server",
-        "//lib:guava",
-        "//lib:truth-java8-extension",
-        "//lib/commons:codec",
-    ],
-)
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/GroupRebuilderTest.java b/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
index 4e9fc7f..e2177e2 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupRebuilderTest.java
@@ -16,7 +16,6 @@
 
 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;
 
@@ -31,58 +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.MetaDataUpdate;
 import com.google.gerrit.server.group.InternalGroup;
 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.sql.Timestamp;
-import java.util.TimeZone;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.IntStream;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 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(
             GroupRebuilderTest::newPersonIdent,
-            new AllUsersName(AllUsersNameProvider.DEFAULT),
+            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
@@ -97,7 +86,7 @@
 
     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);
@@ -114,7 +103,7 @@
 
     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");
@@ -132,7 +121,7 @@
 
     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");
@@ -142,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
@@ -161,7 +150,7 @@
 
     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");
@@ -187,7 +176,7 @@
 
     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");
@@ -211,7 +200,7 @@
 
     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");
@@ -236,13 +225,13 @@
 
     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
@@ -257,12 +246,12 @@
 
     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
@@ -287,7 +276,7 @@
 
     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");
@@ -306,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
@@ -334,7 +323,7 @@
 
     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");
@@ -345,12 +334,12 @@
         "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");
   }
 
   @Test
@@ -369,12 +358,12 @@
 
     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>");
 
     assertThat(log.stream().map(c -> c.committer.date).collect(toImmutableList()))
         .named("%s", log)
@@ -410,14 +399,14 @@
     assertThat(log(g1)).hasSize(1);
     assertThat(log(g2)).hasSize(1);
     assertThat(logGroupNames()).hasSize(1);
-    assertThat(reload(g1)).isEqualTo(b1.toInternalGroup());
-    assertThat(reload(g2)).isEqualTo(b2.toInternalGroup());
+    assertThat(reload(g1)).isEqualTo(b1);
+    assertThat(reload(g2)).isEqualTo(b2);
 
     assertThat(GroupTestUtil.readNameToUuidMap(repo)).containsExactly("a", "a-1", "b", "b-2");
   }
 
-  private InternalGroup reload(AccountGroup g) throws Exception {
-    return removeRefState(GroupConfig.loadForGroup(repo, g.getGroupUUID()).getLoadedGroup().get());
+  private GroupBundle reload(AccountGroup g) throws Exception {
+    return bundleFactory.fromNoteDb(repo, g.getGroupUUID());
   }
 
   private AccountGroup newGroup(String name) {
@@ -487,28 +476,7 @@
     return GroupTestUtil.log(repo, REFS_GROUPNAMES);
   }
 
-  private static void assertServerCommit(CommitInfo commitInfo, String expectedMessage) {
-    assertCommit(commitInfo, expectedMessage, SERVER_NAME, SERVER_EMAIL);
-  }
-
-  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();
   }
-
-  private static PersonIdent newPersonIdent() {
-    return new PersonIdent(SERVER_NAME, SERVER_EMAIL, TimeUtil.nowTs(), TZ);
-  }
 }
diff --git a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index c1a0ac8..eb31abd 100644
--- a/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/javatests/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -18,8 +18,6 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
-import com.google.gerrit.server.diff.IntraLineDiff;
-import com.google.gerrit.server.diff.Text;
 import java.util.List;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.EditList;
diff --git a/javatests/com/google/gerrit/server/patch/PatchListCacheTest.java b/javatests/com/google/gerrit/server/patch/PatchListCacheTest.java
deleted file mode 100644
index 788802c..0000000
--- a/javatests/com/google/gerrit/server/patch/PatchListCacheTest.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.patch;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.server.patch.PatchListCacheImpl.LargeObjectTombstone;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.InputStream;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
-import org.junit.Test;
-
-public class PatchListCacheTest {
-  @Test
-  public void largeObjectTombstoneCanBeSerializedAndDeserialized() throws Exception {
-    // Serialize
-    byte[] serializedObject;
-    try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        ObjectOutputStream objectStream = new ObjectOutputStream(baos)) {
-      objectStream.writeObject(new LargeObjectTombstone());
-      serializedObject = baos.toByteArray();
-      assertThat(serializedObject).isNotNull();
-    }
-    // Deserialize
-    try (InputStream is = new ByteArrayInputStream(serializedObject);
-        ObjectInputStream ois = new ObjectInputStream(is)) {
-      assertThat(ois.readObject()).isInstanceOf(LargeObjectTombstone.class);
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/diff/PatchListEntryTest.java b/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
similarity index 96%
rename from javatests/com/google/gerrit/server/diff/PatchListEntryTest.java
rename to javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
index 49f5815..81f03af 100644
--- a/javatests/com/google/gerrit/server/diff/PatchListEntryTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListEntryTest.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertNull;
diff --git a/javatests/com/google/gerrit/server/diff/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
similarity index 67%
rename from javatests/com/google/gerrit/server/diff/PatchListTest.java
rename to javatests/com/google/gerrit/server/patch/PatchListTest.java
index 03c1515..0a7b97cc 100644
--- a/javatests/com/google/gerrit/server/diff/PatchListTest.java
+++ b/javatests/com/google/gerrit/server/patch/PatchListTest.java
@@ -12,11 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.diff;
+package com.google.gerrit.server.patch;
 
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.Patch;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
 import java.util.Arrays;
 import java.util.Comparator;
 import org.junit.Test;
@@ -65,4 +70,21 @@
         });
     assertThat(names).isEqualTo(want);
   }
+
+  @Test
+  public void largeObjectTombstoneCanBeSerializedAndDeserialized() throws Exception {
+    // Serialize
+    byte[] serializedObject;
+    try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        ObjectOutputStream objectStream = new ObjectOutputStream(baos)) {
+      objectStream.writeObject(new PatchListCacheImpl.LargeObjectTombstone());
+      serializedObject = baos.toByteArray();
+      assertThat(serializedObject).isNotNull();
+    }
+    // Deserialize
+    try (InputStream is = new ByteArrayInputStream(serializedObject);
+        ObjectInputStream ois = new ObjectInputStream(is)) {
+      assertThat(ois.readObject()).isInstanceOf(PatchListCacheImpl.LargeObjectTombstone.class);
+    }
+  }
 }
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-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 8007993..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
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..936b284 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
@@ -329,6 +329,7 @@
         </span>
       </section>
       <gr-endpoint-decorator name="change-metadata-item">
+        <gr-endpoint-param name="change" value="[[change]]"></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-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 3365098..9e58204 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
@@ -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,8 +350,7 @@
               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">
@@ -365,6 +371,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)]]">
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 a4f621b..cd56752 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',
 
@@ -1241,23 +1249,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 7d81f4d..4a4cd5c 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
@@ -120,8 +120,8 @@
 
       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');
 
@@ -840,14 +840,16 @@
       });
     });
 
-    test('_openReplyDialog called with `ANY` when coming from tap event', () => {
-      const openStub = sandbox.stub(element, '_openReplyDialog');
-      element.$.actions.fire('reply-tap');
-      assert(openStub.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.ANY),
-          '_openReplyDialog should have been passed ANY');
-      assert.equal(openStub.callCount, 1);
-    });
+    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', () => {
@@ -967,8 +969,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: {}};
       });
 
@@ -1018,8 +1020,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', () => {
@@ -1169,29 +1171,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}};
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 1830216..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,9 +237,9 @@
 
     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;
           });
 
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 3cede6a..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();
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/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-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 55acfd5..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
@@ -845,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
       );