Merge changes Ie54178b7,I6fda4cce,I2f82060e,I9f95d6fc,I1bbdda5a

* changes:
  Add Gerrit.getLoggedIn to JS API
  Add change view reply dialog JS interface
  Add change view actions JS interface
  Add experimental labelchange event in JS API
  Add optional version parameter to JS API
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 7aead9a..c6eac00 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -80,12 +80,6 @@
 a change successfully merged to the head.  It is a `ChangeEmail`: see
 `ChangeSubject.vm` and `ChangeFooter.vm`.
 
-=== MergeFail.vm
-
-The `MergeFail.vm` template will determine the contents of the email related
-to a failure upon attempting to merge a change to the head.  It is a
-`ChangeEmail`: see `ChangeSubject.vm` and `ChangeFooter.vm`.
-
 === NewChange.vm
 
 The `NewChange.vm` template will determine the contents of the email related
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 2a58041..0b275c1 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -633,7 +633,7 @@
 @Singleton
 public class SampleOperator
     implements ChangeQueryBuilder.ChangeOperatorFactory {
-  public static class MyPredicate extends OperatorPredicate<ChangeData> {
+  public static class MyPredicate extends OperatorChangePredicate<ChangeData> {
     ...
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 95dd556..d8f2885 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
@@ -34,6 +35,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -43,6 +45,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchLineCommentsUtil;
 import com.google.gerrit.server.change.PostReview;
@@ -52,11 +55,12 @@
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.RepoRefCache;
+import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
-import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbChecker;
 import com.google.gerrit.testutil.NoteDbMode;
@@ -260,13 +264,13 @@
 
     // First write doesn't create the ref, but rebuilding works.
     checker.assertNoChangeRef(project, id);
-    assertThat(unwrapDb().changes().get(id).getNoteDbState()).isNull();
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNull();
     checker.rebuildAndCheckChanges(id);
 
     // Now that there is a ref, writes are "turned on" for this change, and
     // NoteDb stays up to date without explicit rebuilding.
     gApi.changes().id(id.get()).topic(name("new-topic"));
-    assertThat(unwrapDb().changes().get(id).getNoteDbState()).isNotNull();
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isNotNull();
     checker.checkChanges(id);
   }
 
@@ -319,13 +323,13 @@
     Change.Id id = r.getPatchSetId().getParentKey();
 
     ObjectId changeMetaId = getMetaRef(project, changeMetaRef(id));
-    assertThat(unwrapDb().changes().get(id).getNoteDbState()).isEqualTo(
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
         changeMetaId.name());
 
     putDraft(user, id, 1, "comment by user");
     ObjectId userDraftsId = getMetaRef(
         allUsers, refsDraftComments(id, user.getId()));
-    assertThat(unwrapDb().changes().get(id).getNoteDbState()).isEqualTo(
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
         changeMetaId.name()
         + "," + user.getId() + "=" + userDraftsId.name());
 
@@ -333,7 +337,7 @@
     ObjectId adminDraftsId = getMetaRef(
         allUsers, refsDraftComments(id, admin.getId()));
     assertThat(admin.getId().get()).isLessThan(user.getId().get());
-    assertThat(unwrapDb().changes().get(id).getNoteDbState()).isEqualTo(
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
         changeMetaId.name()
         + "," + admin.getId() + "=" + adminDraftsId.name()
         + "," + user.getId() + "=" + userDraftsId.name());
@@ -341,7 +345,7 @@
     putDraft(admin, id, 2, "revised comment by admin");
     adminDraftsId = getMetaRef(
         allUsers, refsDraftComments(id, admin.getId()));
-    assertThat(unwrapDb().changes().get(id).getNoteDbState()).isEqualTo(
+    assertThat(getUnwrappedDb().changes().get(id).getNoteDbState()).isEqualTo(
         changeMetaId.name()
         + "," + admin.getId() + "=" + adminDraftsId.name()
         + "," + user.getId() + "=" + userDraftsId.name());
@@ -370,7 +374,7 @@
     // Check that the bundles are equal.
     ChangeBundle actual = ChangeBundle.fromNotes(
         plcUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
   }
 
@@ -421,7 +425,7 @@
     // Check that the bundles are equal.
     ChangeNotes notes = notesFactory.create(dbProvider.get(), project, id);
     ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
     assertThat(
             Iterables.transform(
@@ -460,7 +464,7 @@
     // Check that the bundles are equal.
     ChangeBundle actual = ChangeBundle.fromNotes(
         plcUtil, notesFactory.create(dbProvider.get(), project, id));
-    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
   }
 
@@ -490,7 +494,7 @@
     assertChangeUpToDate(false, id);
     assertThat(getMetaRef(project, changeMetaRef(id))).isEqualTo(oldMetaId);
     ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
     assertChangeUpToDate(false, id);
 
@@ -532,7 +536,7 @@
     // Not up to date, but the actual returned state matches anyway.
     assertDraftsUpToDate(false, id, user);
     ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
 
     // Another rebuild attempt succeeds
@@ -560,7 +564,7 @@
     setNotesMigration(false, false);
     putDraft(user, id, 1, "second comment by user");
 
-    ReviewDb db = unwrapDb();
+    ReviewDb db = getUnwrappedDb();
     Change c = db.changes().get(id);
     // Leave change meta ID alone so DraftCommentNotes does the rebuild.
     NoteDbChangeState bogusState = new NoteDbChangeState(
@@ -587,7 +591,7 @@
     assertChangeUpToDate(true, id);
     assertDraftsUpToDate(false, id, user);
     ChangeBundle actual = ChangeBundle.fromNotes(plcUtil, notes);
-    ChangeBundle expected = ChangeBundle.fromReviewDb(unwrapDb(), id);
+    ChangeBundle expected = ChangeBundle.fromReviewDb(getUnwrappedDb(), id);
     assertThat(actual.differencesFrom(expected)).isEmpty();
 
     // Another rebuild attempt succeeds
@@ -880,8 +884,73 @@
     }
   }
 
+  @Test
+  public void failWhenWritesDisabled() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
+
+    // Turning off writes causes failure.
+    setNotesMigration(false, true);
+    try {
+      gApi.changes().id(id.get()).topic(name("a-topic"));
+      fail("Expected write to fail");
+    } catch (RestApiException e) {
+      assertChangesReadOnly(e);
+    }
+
+    // Update was not written.
+    assertThat(gApi.changes().id(id.get()).info().topic).isNull();
+    assertChangeUpToDate(true, id);
+  }
+
+  @Test
+  public void rebuildWhenWritesDisabledWorksButDoesNotWrite() throws Exception {
+    setNotesMigration(true, true);
+
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    assertChangeUpToDate(true, id);
+
+    // Make a ReviewDb change behind NoteDb's back and ensure it's detected.
+    setNotesMigration(false, false);
+    gApi.changes().id(id.get()).topic(name("a-topic"));
+    setInvalidNoteDbState(id);
+    assertChangeUpToDate(false, id);
+
+    // On next NoteDb read, change is rebuilt in-memory but not stored.
+    setNotesMigration(false, true);
+    assertThat(gApi.changes().id(id.get()).info().topic)
+        .isEqualTo(name("a-topic"));
+    assertChangeUpToDate(false, id);
+
+    // Attempting to write directly causes failure.
+    try {
+      gApi.changes().id(id.get()).topic(name("other-topic"));
+      fail("Expected write to fail");
+    } catch (RestApiException e) {
+      assertChangesReadOnly(e);
+    }
+
+    // Update was not written.
+    assertThat(gApi.changes().id(id.get()).info().topic)
+        .isEqualTo(name("a-topic"));
+    assertChangeUpToDate(false, id);
+  }
+
+  private void assertChangesReadOnly(RestApiException e) throws Exception {
+    Throwable cause = e.getCause();
+    assertThat(cause).isInstanceOf(UpdateException.class);
+    assertThat(cause.getCause()).isInstanceOf(OrmException.class);
+    assertThat(cause.getCause())
+        .hasMessage(NoteDbUpdateManager.CHANGES_READ_ONLY);
+  }
+
   private void setInvalidNoteDbState(Change.Id id) throws Exception {
-    ReviewDb db = unwrapDb();
+    ReviewDb db = getUnwrappedDb();
     Change c = db.changes().get(id);
     // In reality we would have NoteDb writes enabled, which would write a real
     // state into this field. For tests however, we turn NoteDb writes off, so
@@ -894,7 +963,7 @@
   private void assertChangeUpToDate(boolean expected, Change.Id id)
       throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
-      Change c = unwrapDb().changes().get(id);
+      Change c = getUnwrappedDb().changes().get(id);
       assertThat(c).isNotNull();
       assertThat(c.getNoteDbState()).isNotNull();
       assertThat(NoteDbChangeState.parse(c).isChangeUpToDate(
@@ -906,7 +975,7 @@
   private void assertDraftsUpToDate(boolean expected, Change.Id changeId,
       TestAccount account) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      Change c = unwrapDb().changes().get(changeId);
+      Change c = getUnwrappedDb().changes().get(changeId);
       assertThat(c).isNotNull();
       assertThat(c.getNoteDbState()).isNotNull();
       NoteDbChangeState state = NoteDbChangeState.parse(c);
@@ -983,11 +1052,8 @@
     return msg;
   }
 
-  private ReviewDb unwrapDb() {
+  private ReviewDb getUnwrappedDb() {
     ReviewDb db = dbProvider.get();
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
+    return ReviewDbUtil.unwrapDb(db);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeIndexedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
similarity index 78%
rename from gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeIndexedListener.java
rename to gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
index f996724..fd8dac8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeIndexedListener.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ChangeIndexedListener.java
@@ -12,18 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.extensions.events;
+package com.google.gerrit.extensions.events;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.change.ChangeData;
 
 /** Notified whenever a change is indexed or deleted from the index. */
 @ExtensionPoint
 public interface ChangeIndexedListener {
   /** Invoked when a change is indexed. */
-  void onChangeIndexed(ChangeData change);
+  void onChangeIndexed(int id);
 
   /** Invoked when a change is deleted from the index. */
-  void onChangeDeleted(Change.Id id);
+  void onChangeDeleted(int id);
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
index 9991a76..306a69f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -48,7 +49,6 @@
 import com.google.gerrit.server.index.change.ReindexAfterUpdate;
 import com.google.gerrit.server.notedb.ChangeRebuilder;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
@@ -153,7 +153,7 @@
             new Callable<Boolean>() {
               @Override
               public Boolean call() {
-                try (ReviewDb db = unwrap(schemaFactory.open())) {
+                try (ReviewDb db = unwrapDb(schemaFactory.open())) {
                   return rebuilder.rebuildProject(
                       db, changesByProject, project, allUsersRepo);
                 } catch (Exception e) {
@@ -234,7 +234,7 @@
         ArrayListMultimap.create();
     try (ReviewDb db = schemaFactory.open()) {
       if (projects.isEmpty() && !changes.isEmpty()) {
-        Iterable<Change> todo = unwrap(db).changes().get(
+        Iterable<Change> todo = unwrapDb(db).changes().get(
             Iterables.transform(changes, new Function<Integer, Change.Id>() {
               @Override
               public Change.Id apply(Integer in) {
@@ -245,7 +245,7 @@
           changesByProject.put(c.getProject(), c.getId());
         }
       } else {
-        for (Change c : unwrap(db).changes().all()) {
+        for (Change c : unwrapDb(db).changes().all()) {
           boolean include = false;
           if (projects.isEmpty() && changes.isEmpty()) {
             include = true;
@@ -263,11 +263,4 @@
       return ImmutableMultimap.copyOf(changesByProject);
     }
   }
-
-  private static ReviewDb unwrap(ReviewDb db) {
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
index 540ba0b..198916a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
@@ -399,7 +400,7 @@
           System.err.flush();
 
         } else if (ui.yesno(true, "%s\nExecute now", msg)) {
-          try (JdbcSchema db = (JdbcSchema) schema.open();
+          try (JdbcSchema db = (JdbcSchema) unwrapDb(schema.open());
               JdbcExecutor e = new JdbcExecutor(db)) {
             for (String sql : pruneList) {
               e.execute(sql);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 7f86a8a..f16e2ec 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -109,7 +109,6 @@
     extractMailExample("DeleteVote.vm");
     extractMailExample("Footer.vm");
     extractMailExample("Merged.vm");
-    extractMailExample("MergeFail.vm");
     extractMailExample("NewChange.vm");
     extractMailExample("RegisterNewEmail.vm");
     extractMailExample("ReplacePatchSet.vm");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DisabledChangesReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java
similarity index 93%
rename from gerrit-server/src/main/java/com/google/gerrit/server/schema/DisabledChangesReviewDbWrapper.java
rename to gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java
index 658f3bb..b70778e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DisabledChangesReviewDbWrapper.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/DisabledChangesReviewDbWrapper.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.schema;
+package com.google.gerrit.reviewdb.server;
 
 import com.google.common.util.concurrent.CheckedFuture;
 import com.google.gerrit.reviewdb.client.Account;
@@ -21,13 +21,6 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ChangeAccess;
-import com.google.gerrit.reviewdb.server.ChangeMessageAccess;
-import com.google.gerrit.reviewdb.server.PatchLineCommentAccess;
-import com.google.gerrit.reviewdb.server.PatchSetAccess;
-import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 
@@ -40,7 +33,7 @@
   private final DisabledPatchSetAccess patchSets;
   private final DisabledPatchLineCommentAccess patchComments;
 
-  DisabledChangesReviewDbWrapper(ReviewDb db) {
+  public DisabledChangesReviewDbWrapper(ReviewDb db) {
     super(db);
     changes = new DisabledChangeAccess(delegate.changes());
     patchSetApprovals =
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
index 5d782dd..42d0993 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
@@ -49,6 +49,13 @@
     return CHANGE_ID_FUNCTION;
   }
 
+  public static ReviewDb unwrapDb(ReviewDb db) {
+    if (db instanceof DisabledChangesReviewDbWrapper) {
+      return ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
+    }
+    return db;
+  }
+
   private ReviewDbUtil() {
   }
 }
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
index 6aa9e2c..4fc578c 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -190,6 +190,7 @@
     ['src/test/java/**/*.java'],
     excludes = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS
   ),
+  resources = glob(['src/test/resources/com/google/gerrit/server/mail/*']),
   deps = TESTUTIL_DEPS + [
     ':testutil',
     '//gerrit-antlr:query_exception',
@@ -202,6 +203,7 @@
     '//lib:guava',
     '//lib:guava-retrying',
     '//lib:protobuf',
+    '//lib/commons:validator',
     '//lib/dropwizard:dropwizard-core',
     '//lib/guice:guice-assistedinject',
     '//lib/prolog:runtime',
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
index 97bc2e5..0029768 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 /** Distributes Events to listeners if they are allowed to see them */
@@ -60,21 +61,25 @@
 
   protected final ChangeNotes.Factory notesFactory;
 
+  protected final Provider<ReviewDb> dbProvider;
+
   @Inject
   public EventBroker(DynamicSet<UserScopedEventListener> listeners,
       DynamicSet<EventListener> unrestrictedListeners,
       ProjectCache projectCache,
-      ChangeNotes.Factory notesFactory) {
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider) {
     this.listeners = listeners;
     this.unrestrictedListeners = unrestrictedListeners;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
   }
 
   @Override
-  public void postEvent(Change change, ChangeEvent event, ReviewDb db)
+  public void postEvent(Change change, ChangeEvent event)
       throws OrmException {
-    fireEvent(change, event, db);
+    fireEvent(change, event);
   }
 
   @Override
@@ -88,8 +93,8 @@
   }
 
   @Override
-  public void postEvent(Event event, ReviewDb db) throws OrmException {
-    fireEvent(event, db);
+  public void postEvent(Event event) throws OrmException {
+    fireEvent(event);
   }
 
   protected void fireEventForUnrestrictedListeners(Event event) {
@@ -98,10 +103,10 @@
     }
   }
 
-  protected void fireEvent(Change change, ChangeEvent event, ReviewDb db)
+  protected void fireEvent(Change change, ChangeEvent event)
       throws OrmException {
     for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(change, listener.getUser(), db)) {
+      if (isVisibleTo(change, listener.getUser())) {
         listener.onEvent(event);
       }
     }
@@ -126,9 +131,9 @@
     fireEventForUnrestrictedListeners(event);
   }
 
-  protected void fireEvent(Event event, ReviewDb db) throws OrmException {
+  protected void fireEvent(Event event) throws OrmException {
     for (UserScopedEventListener listener : listeners) {
-      if (isVisibleTo(event, listener.getUser(), db)) {
+      if (isVisibleTo(event, listener.getUser())) {
         listener.onEvent(event);
       }
     }
@@ -143,7 +148,7 @@
     return pe.controlFor(user).isVisible();
   }
 
-  protected boolean isVisibleTo(Change change, CurrentUser user, ReviewDb db)
+  protected boolean isVisibleTo(Change change, CurrentUser user)
       throws OrmException {
     if (change == null) {
       return false;
@@ -153,6 +158,7 @@
       return false;
     }
     ProjectControl pc = pe.controlFor(user);
+    ReviewDb db = dbProvider.get();
     return pc.controlFor(db, change).isVisible(db);
   }
 
@@ -165,16 +171,16 @@
     return pc.controlForRef(branchName).isVisible();
   }
 
-  protected boolean isVisibleTo(Event event, CurrentUser user, ReviewDb db)
+  protected boolean isVisibleTo(Event event, CurrentUser user)
       throws OrmException {
     if (event instanceof RefEvent) {
       RefEvent refEvent = (RefEvent) event;
       String ref = refEvent.getRefName();
       if (PatchSet.isChangeRef(ref)) {
         Change.Id cid = PatchSet.Id.fromRef(ref).getParentKey();
-        Change change = notesFactory
-            .create(db, refEvent.getProjectNameKey(), cid).getChange();
-        return isVisibleTo(change, user, db);
+        Change change = notesFactory.create(
+            dbProvider.get(), refEvent.getProjectNameKey(), cid).getChange();
+        return isVisibleTo(change, user);
       }
       return isVisibleTo(refEvent.getBranchNameKey(), user);
     } else if (event instanceof ProjectEvent) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
index 913fae3..46b979a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.events.ChangeEvent;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.ProjectEvent;
@@ -32,10 +31,9 @@
    *
    * @param change The change that the event is related to
    * @param event The event to post
-   * @param db The database
    * @throws OrmException
    */
-  void postEvent(Change change, ChangeEvent event, ReviewDb db)
+  void postEvent(Change change, ChangeEvent event)
       throws OrmException;
 
   /**
@@ -62,7 +60,6 @@
    * for those use cases.
    *
    * @param event The event to post.
-   * @param db The database.
    */
-  void postEvent(Event event, ReviewDb db) throws OrmException;
+  void postEvent(Event event) throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/common/StreamEventsApiListener.java
index 85f4c59..788147f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/StreamEventsApiListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/StreamEventsApiListener.java
@@ -287,7 +287,7 @@
       event.changer = accountAttributeSupplier(ev.getEditor());
       event.oldTopic = ev.getOldTopic();
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -305,7 +305,7 @@
       event.patchSet = patchSetAttributeSupplier(change, patchSet);
       event.uploader = accountAttributeSupplier(ev.getUploader());
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -325,7 +325,7 @@
       event.approvals = approvalsAttributeSupplier(change,
           ev.getNewApprovals(), ev.getOldApprovals());
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -344,7 +344,7 @@
           psUtil.current(db.get(), notes));
       event.reviewer = accountAttributeSupplier(ev.getReviewer());
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -371,7 +371,7 @@
       event.added = hashtagArray(ev.getAddedHashtags());
       event.removed = hashtagArray(ev.getRemovedHashtags());
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -410,7 +410,7 @@
       event.patchSet = patchSetAttributeSupplier(change, ps);
       event.uploader = accountAttributeSupplier(ev.getPublisher());
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -431,7 +431,7 @@
       event.approvals = approvalsAttributeSupplier(
           change, ev.getApprovals(), ev.getOldApprovals());
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -450,7 +450,7 @@
           psUtil.current(db.get(), notes));
       event.reason = ev.getReason();
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -469,7 +469,7 @@
           psUtil.current(db.get(), notes));
       event.newRev = ev.getNewRevisionId();
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
@@ -488,7 +488,7 @@
           psUtil.current(db.get(), notes));
       event.reason = ev.getReason();
 
-      dispatcher.get().postEvent(change, event, db.get());
+      dispatcher.get().postEvent(change, event);
     } catch (OrmException e) {
       log.error("Failed to dispatch event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
index df818ac..168dbf7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
@@ -83,6 +83,13 @@
       this.disableReverseDnsLookup = disableReverseDnsLookup;
     }
 
+    public IdentifiedUser create(AccountState state) {
+      return new IdentifiedUser(capabilityControlFactory, authConfig, realm,
+          anonymousCowardName, canonicalUrl, accountCache, groupBackend,
+          disableReverseDnsLookup, Providers.of((SocketAddress) null), state,
+          null);
+    }
+
     public IdentifiedUser create(Account.Id id) {
       return create((SocketAddress) null, id);
     }
@@ -179,15 +186,33 @@
 
   private IdentifiedUser(
       CapabilityControl.Factory capabilityControlFactory,
-      final AuthConfig authConfig,
+      AuthConfig authConfig,
       Realm realm,
-      final String anonymousCowardName,
-      final Provider<String> canonicalUrl,
-      final AccountCache accountCache,
-      final GroupBackend groupBackend,
-      final Boolean disableReverseDnsLookup,
-      @Nullable final Provider<SocketAddress> remotePeerProvider,
-      final Account.Id id,
+      String anonymousCowardName,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean disableReverseDnsLookup,
+      @Nullable Provider<SocketAddress> remotePeerProvider,
+      AccountState state,
+      @Nullable CurrentUser realUser) {
+    this(capabilityControlFactory, authConfig, realm, anonymousCowardName,
+        canonicalUrl, accountCache, groupBackend, disableReverseDnsLookup,
+        remotePeerProvider, state.getAccount().getId(), realUser);
+    this.state = state;
+  }
+
+  private IdentifiedUser(
+      CapabilityControl.Factory capabilityControlFactory,
+      AuthConfig authConfig,
+      Realm realm,
+      String anonymousCowardName,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean disableReverseDnsLookup,
+      @Nullable Provider<SocketAddress> remotePeerProvider,
+      Account.Id id,
       @Nullable CurrentUser realUser) {
     super(capabilityControlFactory);
     this.canonicalUrl = canonicalUrl;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
index 05ae51f..c5b0699 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
@@ -78,6 +78,10 @@
     this.accountVisibility = accountVisibility;
   }
 
+  public CurrentUser getUser() {
+    return user;
+  }
+
   /**
    * Returns true if the current user is allowed to see the otherUser, based
    * on the account visibility policy. Depending on the group membership
@@ -86,7 +90,7 @@
    * {@link GroupMembership#getKnownGroups()} may only return a subset of the
    * effective groups.
    */
-  public boolean canSee(final Account otherUser) {
+  public boolean canSee(Account otherUser) {
     return canSee(otherUser.getId());
   }
 
@@ -99,8 +103,45 @@
    * effective groups.
    */
   public boolean canSee(final Account.Id otherUser) {
+    return canSee(new OtherUser() {
+      @Override
+      Account.Id getId() {
+        return otherUser;
+      }
+
+      @Override
+      IdentifiedUser createUser() {
+        return userFactory.create(otherUser);
+      }
+    });
+  }
+
+  /**
+   * Returns true if the current user is allowed to see the otherUser, based
+   * on the account visibility policy. Depending on the group membership
+   * realms supported, this may not be able to determine SAME_GROUP or
+   * VISIBLE_GROUP correctly (defaulting to not being visible). This is because
+   * {@link GroupMembership#getKnownGroups()} may only return a subset of the
+   * effective groups.
+   */
+  public boolean canSee(final AccountState otherUser) {
+    return canSee(new OtherUser() {
+      @Override
+      Account.Id getId() {
+        return otherUser.getAccount().getId();
+      }
+
+      @Override
+      IdentifiedUser createUser() {
+        return userFactory.create(otherUser);
+      }
+    });
+  }
+
+  private boolean canSee(OtherUser otherUser) {
     // Special case: I can always see myself.
-    if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser)) {
+    if (user.isIdentifiedUser()
+        && user.getAccountId().equals(otherUser.getId())) {
       return true;
     }
     if (user.getCapabilities().canViewAllAccounts()) {
@@ -111,7 +152,7 @@
       case ALL:
         return true;
       case SAME_GROUP: {
-        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser);
+        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
         for (PermissionRule rule : accountsSection.getSameGroupVisibility()) {
           if (rule.isBlock() || rule.isDeny()) {
             usersGroups.remove(rule.getGroup().getUUID());
@@ -124,7 +165,7 @@
         break;
       }
       case VISIBLE_GROUP: {
-        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser);
+        Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
         for (AccountGroup.UUID usersGroup : usersGroups) {
           try {
             if (groupControlFactory.controlFor(usersGroup).isVisible()) {
@@ -144,9 +185,9 @@
     return false;
   }
 
-  private Set<AccountGroup.UUID> groupsOf(Account.Id account) {
+  private Set<AccountGroup.UUID> groupsOf(IdentifiedUser user) {
     return new HashSet<>(Sets.filter(
-      userFactory.create(account).getEffectiveGroups().getKnownGroups(),
+      user.getEffectiveGroups().getKnownGroups(),
       new Predicate<AccountGroup.UUID>() {
         @Override
         public boolean apply(AccountGroup.UUID in) {
@@ -154,4 +195,18 @@
         }
       }));
   }
+
+  private abstract static class OtherUser {
+    IdentifiedUser user;
+
+    IdentifiedUser getUser() {
+      if (user == null) {
+        user = createUser();
+      }
+      return user;
+    }
+
+    abstract IdentifiedUser createUser();
+    abstract Account.Id getId();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
index 9f8411f..3ca0e1b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -33,7 +34,6 @@
 import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.BatchUpdateReviewDb;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 
@@ -57,10 +57,7 @@
     if (db instanceof BatchUpdateReviewDb) {
       db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
     }
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
+    return ReviewDbUtil.unwrapDb(db);
   }
 
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index 93cb01b..1cd8726 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -146,6 +146,7 @@
       psUtil.delete(ctx.getDb(), ctx.getUpdate(patchSet.getId()), patchSet);
 
       accountPatchReviewStore.get().clearReviewed(psId);
+      // Use the unwrap from DeleteDraftChangeOp to handle BatchUpdateReviewDb.
       ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
       db.changeMessages().delete(db.changeMessages().byPatchSet(psId));
       db.patchComments().delete(db.patchComments().byPatchSet(psId));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
index 842e8bb..08ef76e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -32,7 +33,6 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -186,13 +186,10 @@
       throws OrmException {
     // Empty update of Change to bump rowVersion, changing its ETag.
     // TODO(dborowitz): Include cache info in ETag somehow instead.
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
+    db = ReviewDbUtil.unwrapDb(db);
     Change c = db.changes().get(id);
     if (c != null) {
       db.changes().update(Collections.singleton(c));
     }
   }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
index 9fa966e..5fe0e0b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
@@ -54,7 +54,7 @@
   public Response<?> apply(ChangeResource rsrc, Input input)
       throws ResourceNotFoundException, IOException, OrmException,
       ConfigInvalidException {
-    if (!migration.writeChanges()) {
+    if (!migration.commitChangeWrites()) {
       throw new ResourceNotFoundException();
     }
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 9781f39d..6bbfd62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.events.AgreementSignupListener;
 import com.google.gerrit.extensions.events.ChangeAbandonedListener;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -97,7 +98,6 @@
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
-import com.google.gerrit.server.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.EmailMerge;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
index 5a0db21..3bc9e88 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
@@ -20,7 +20,12 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.inject.Inject;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 public class AgreementSignup {
+  private static final Logger log =
+      LoggerFactory.getLogger(AgreementSignup.class);
 
   private final DynamicSet<AgreementSignupListener> listeners;
   private final EventUtil util;
@@ -36,9 +41,13 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(util.accountInfo(account), agreementName);
+    Event event = new Event(util.accountInfo(account), agreementName);
     for (AgreementSignupListener l : listeners) {
-      l.onAgreementSignup(e);
+      try {
+        l.onAgreementSignup(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index 7ab8eb0..6c23f60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -51,9 +51,13 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(change, revision, abandoner, reason);
+    Event event = new Event(change, revision, abandoner, reason);
     for (ChangeAbandonedListener l : listeners) {
-      l.onChangeAbandoned(e);
+      try {
+        l.onChangeAbandoned(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 1677166..6a27275 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -34,7 +34,7 @@
 
 public class ChangeMerged {
   private static final Logger log =
-      LoggerFactory.getLogger(ChangeAbandoned.class);
+      LoggerFactory.getLogger(ChangeMerged.class);
 
   private final DynamicSet<ChangeMergedListener> listeners;
   private final EventUtil util;
@@ -51,9 +51,13 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(change, revision, merger, newRevisionId);
+    Event event = new Event(change, revision, merger, newRevisionId);
     for (ChangeMergedListener l : listeners) {
-      l.onChangeMerged(e);
+      try {
+        l.onChangeMerged(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 2d8381a..9981902 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -48,12 +48,16 @@
 
   public void fire(ChangeInfo change, RevisionInfo revision,
       AccountInfo restorer, String reason) {
-    Event e = new Event(change, revision, restorer, reason);
     if (!listeners.iterator().hasNext()) {
       return;
     }
+    Event event = new Event(change, revision, restorer, reason);
     for (ChangeRestoredListener l : listeners) {
-      l.onChangeRestored(e);
+      try {
+        l.onChangeRestored(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index aa17517..15f82b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -55,10 +55,14 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(
+    Event event = new Event(
         change, revision, author, comment, approvals, oldApprovals);
     for (CommentAddedListener l : listeners) {
-      l.onCommentAdded(e);
+      try {
+        l.onCommentAdded(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
index bc1772d..433f717 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
@@ -51,9 +51,13 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(change, revision, publisher);
+    Event event = new Event(change, revision, publisher);
     for (DraftPublishedListener l : listeners) {
-      l.onDraftPublished(e);
+      try {
+        l.onDraftPublished(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 0af2274..0ce2a7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.extensions.events;
 
+import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -35,6 +36,7 @@
 
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -52,7 +54,8 @@
       AccountCache accountCache) {
     this.changeDataFactory = changeDataFactory;
     this.db = db;
-    this.changeJson = changeJsonFactory.create(ChangeJson.NO_OPTIONS);
+    this.changeJson = changeJsonFactory.create(
+        EnumSet.of(ListChangesOption.CURRENT_COMMIT));
     this.accountCache = accountCache;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 29a47aa..4de43fe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -142,8 +142,8 @@
     for (GitReferenceUpdatedListener l : listeners) {
       try {
         l.onGitReferenceUpdated(event);
-      } catch (RuntimeException e) {
-        log.warn("Failure in GitReferenceUpdatedListener", e);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index de679e66..692f908 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -46,12 +46,16 @@
 
   public void fire(ChangeInfo change, AccountInfo editor, Collection<String> hashtags,
       Collection<String> added, Collection<String> removed) {
-    Event e = new Event(change, editor, hashtags, added, removed);
     if (!listeners.iterator().hasNext()) {
       return;
     }
+    Event event = new Event(change, editor, hashtags, added, removed);
     for (HashtagsEditedListener l : listeners) {
-      l.onHashtagsEdited(e);
+      try {
+        l.onHashtagsEdited(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index 4cd9be2..ef7f013 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -51,9 +51,13 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(change, revision, reviewer);
+    Event event = new Event(change, revision, reviewer);
     for (ReviewerAddedListener l : listeners) {
-      l.onReviewerAdded(e);
+      try {
+        l.onReviewerAdded(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener, e");
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 0320fc2..204f014 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -56,14 +56,14 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(change,
-        revision,
-        reviewer,
-        message,
-        newApprovals,
-        oldApprovals);
+    Event event = new Event(change, revision, reviewer, message,
+        newApprovals, oldApprovals);
     for (ReviewerDeletedListener listener : listeners) {
-      listener.onReviewerDeleted(e);
+      try {
+        listener.onReviewerDeleted(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 7f03e78..6b2418d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -51,9 +51,13 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(change, revision, uploader);
+    Event event = new Event(change, revision, uploader);
     for (RevisionCreatedListener l : listeners) {
-      l.onRevisionCreated(e);
+      try {
+        l.onRevisionCreated(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index 7b1386d..fc97d58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -44,9 +44,13 @@
     if (!listeners.iterator().hasNext()) {
       return;
     }
-    Event e = new Event(change, editor, oldTopic);
+    Event event = new Event(change, editor, oldTopic);
     for (TopicEditedListener l : listeners) {
-      l.onTopicEdited(e);
+      try {
+        l.onTopicEdited(event);
+      } catch (Exception e) {
+        log.warn("Error in event listener", e);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
index 15ca859..598ed71 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
-import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -636,6 +635,11 @@
 
     List<ChangeTask> tasks = new ArrayList<>(ops.keySet().size());
     try {
+      if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
+        // Fail fast before attempting any writes if changes are read-only, as
+        // this is a programmer error.
+        throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+      }
       List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
       for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
         ChangeTask task =
@@ -645,13 +649,15 @@
       }
       Futures.allAsList(futures).get();
 
-      if (notesMigration.writeChanges()) {
+      if (notesMigration.commitChangeWrites()) {
         executeNoteDbUpdates(tasks);
       }
     } catch (ExecutionException | InterruptedException e) {
       Throwables.propagateIfInstanceOf(e.getCause(), UpdateException.class);
       Throwables.propagateIfInstanceOf(e.getCause(), RestApiException.class);
       throw new UpdateException(e);
+    } catch (OrmException e) {
+      throw new UpdateException(e);
     }
 
     // Reindex changes.
@@ -802,7 +808,7 @@
           deleted = ctx.deleted;
 
           // Stage the NoteDb update and store its state in the Change.
-          if (notesMigration.writeChanges()) {
+          if (notesMigration.commitChangeWrites()) {
             updateManager = stageNoteDbUpdate(ctx, deleted);
           }
 
@@ -821,7 +827,7 @@
           db.rollback();
         }
 
-        if (notesMigration.writeChanges()) {
+        if (notesMigration.commitChangeWrites()) {
           try {
             // Do not execute the NoteDbUpdateManager, as we don't want too much
             // contention on the underlying repo, and we would rather use a
@@ -849,7 +855,7 @@
         RevWalk rw, Change.Id id) throws Exception {
       Change c = newChanges.get(id);
       if (c == null) {
-        c = unwrap(db).changes().get(id);
+        c = ReviewDbUtil.unwrapDb(db).changes().get(id);
       }
       // Pass in preloaded change to controlFor, to avoid:
       //  - reading from a db that does not belong to this update
@@ -896,11 +902,4 @@
       op.postUpdate(ctx);
     }
   }
-
-  private static ReviewDb unwrap(ReviewDb db) {
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
index bbb325e..66637fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
@@ -37,6 +37,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.BiMap;
+import com.google.common.collect.Collections2;
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.HashMultimap;
@@ -50,8 +51,6 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.SortedSetMultimap;
-import com.google.common.util.concurrent.CheckedFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.Capable;
@@ -291,7 +290,6 @@
   private final TagCache tagCache;
   private final AccountCache accountCache;
   private final ChangeInserter.Factory changeInserterFactory;
-  private final ListeningExecutorService changeUpdateExector;
   private final RequestScopePropagator requestScopePropagator;
   private final SshInfo sshInfo;
   private final AllProjectsName allProjectsName;
@@ -355,7 +353,6 @@
       ChangeInserter.Factory changeInserterFactory,
       CommitValidators.Factory commitValidatorsFactory,
       @CanonicalWebUrl String canonicalWebUrl,
-      @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
       RequestScopePropagator requestScopePropagator,
       SshInfo sshInfo,
       AllProjectsName allProjectsName,
@@ -392,7 +389,6 @@
     this.accountCache = accountCache;
     this.changeInserterFactory = changeInserterFactory;
     this.commitValidatorsFactory = commitValidatorsFactory;
-    this.changeUpdateExector = changeUpdateExector;
     this.requestScopePropagator = requestScopePropagator;
     this.sshInfo = sshInfo;
     this.allProjectsName = allProjectsName;
@@ -1892,17 +1888,19 @@
   }
 
   private void readChangesForReplace() throws OrmException {
-    List<CheckedFuture<ChangeNotes, OrmException>> futures =
-        Lists.newArrayListWithCapacity(replaceByChange.size());
-    for (ReplaceRequest request : replaceByChange.values()) {
-      futures.add(notesFactory.createAsync(changeUpdateExector, db,
-          project.getNameKey(), request.ontoChange));
-    }
-    for (CheckedFuture<ChangeNotes, OrmException> f : futures) {
-      ChangeNotes notes = f.checkedGet();
-      if (notes.getChange() != null) {
-        replaceByChange.get(notes.getChangeId()).notes = notes;
-      }
+    Collection<ChangeNotes> allNotes =
+        notesFactory.create(
+            db,
+            Collections2.transform(
+                replaceByChange.values(),
+                new Function<ReplaceRequest, Change.Id>() {
+                  @Override
+                  public Change.Id apply(ReplaceRequest in) {
+                    return in.ontoChange;
+                  }
+                }));
+    for (ChangeNotes notes : allNotes) {
+      replaceByChange.get(notes.getChangeId()).notes = notes;
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
index d3b9e95..ff9ff03 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
@@ -20,7 +20,7 @@
 public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
   private final FieldDef<I, ?> def;
 
-  public IndexPredicate(FieldDef<I, ?> def, String value) {
+  protected IndexPredicate(FieldDef<I, ?> def, String value) {
     super(def.getName(), value);
     this.def = def;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
index 3040ca6..65097b4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
@@ -14,10 +14,8 @@
 
 package com.google.gerrit.server.index;
 
-import com.google.common.base.Function;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
 import com.google.gerrit.server.query.DataSource;
 import com.google.gerrit.server.query.Paginated;
 import com.google.gerrit.server.query.Predicate;
@@ -26,10 +24,7 @@
 import com.google.gwtorm.server.ResultSet;
 
 import java.util.Collection;
-import java.util.HashMap;
-import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
 
 /**
  * Wrapper combining an {@link IndexPredicate} together with a
@@ -48,8 +43,7 @@
 
   private QueryOptions opts;
   private final Predicate<T> pred;
-  private DataSource<T> source;
-  private final Map<T, DataSource<T>> fromSource;
+  protected DataSource<T> source;
 
   public IndexedQuery(Index<I, T> index, Predicate<T> pred,
       QueryOptions opts) throws QueryParseException {
@@ -57,7 +51,6 @@
     this.opts = opts;
     this.pred = pred;
     this.source = index.getSource(pred, this.opts);
-    this.fromSource = new HashMap<>();
   }
 
   @Override
@@ -90,38 +83,7 @@
 
   @Override
   public ResultSet<T> read() throws OrmException {
-    final DataSource<T> currSource = source;
-    final ResultSet<T> rs = currSource.read();
-
-    return new ResultSet<T>() {
-      @Override
-      public Iterator<T> iterator() {
-        return Iterables.transform(
-            rs,
-            new Function<T, T>() {
-              @Override
-              public
-              T apply(T t) {
-                fromSource.put(t, currSource);
-                return t;
-              }
-            }).iterator();
-      }
-
-      @Override
-      public List<T> toList() {
-        List<T> r = rs.toList();
-        for (T t : r) {
-          fromSource.put(t, currSource);
-        }
-        return r;
-      }
-
-      @Override
-      public void close() {
-        rs.close();
-      }
-    };
+    return source.read();
   }
 
   @Override
@@ -146,19 +108,6 @@
   }
 
   @Override
-  public boolean match(T t) throws OrmException {
-    return (source != null && fromSource.get(t) == source) || pred.match(t);
-  }
-
-  @Override
-  public int getCost() {
-    // Index queries are assumed to be cheaper than any other type of query, so
-    // so try to make sure they get picked. Note that pred's cost may be higher
-    // because it doesn't know whether it's being used in an index query or not.
-    return 1;
-  }
-
-  @Override
   public int hashCode() {
     return pred.hashCode();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
index 1259951..52c1201 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
@@ -33,7 +33,6 @@
 
   protected abstract int getValueInt(T object) throws OrmException;
 
-  @Override
   public boolean match(T object) throws OrmException {
     int valueInt = getValueInt(object);
     return valueInt >= range.min && valueInt <= range.max;
@@ -48,9 +47,4 @@
   public int getMaximumValue() {
     return range.max;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
index a9f5442..d0c2095 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
@@ -18,6 +18,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
 
 import java.util.Set;
 
@@ -31,6 +32,15 @@
         ImmutableSet.copyOf(fields));
   }
 
+  public QueryOptions convertForBackend() {
+    // Increase the limit rather than skipping, since we don't know how many
+    // skipped results would have been filtered out by the enclosing AndSource.
+    int backendLimit = config().maxLimit();
+    int limit = Ints.saturatedCast((long) limit() + start());
+    limit = Math.min(limit, backendLimit);
+    return create(config(), 0, limit, fields());
+  }
+
   public abstract IndexConfig config();
   public abstract int start();
   public abstract int limit();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
index 8ba7df9..1e2e80b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
@@ -38,9 +38,4 @@
 
   public abstract Date getMinTimestamp();
   public abstract Date getMaxTimestamp();
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index 8627e3a..0cbcf1f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -114,7 +114,8 @@
                   public String apply(String in) {
                     return in.toLowerCase();
                   }
-                });
+                })
+            .toSet();
         }
       };
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
index a0814e0..76103fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -29,6 +29,6 @@
   public IndexedAccountQuery(Index<Account.Id, AccountState> index,
       Predicate<AccountState> pred, QueryOptions opts)
           throws QueryParseException {
-    super(index, pred, opts);
+    super(index, pred, opts.convertForBackend());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 1fff70d..3523e5f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.AndSource;
+import com.google.gerrit.server.query.change.AndChangeSource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -272,7 +272,7 @@
       Predicate<ChangeData> in,
       List<Predicate<ChangeData>> all) {
     if (in instanceof AndPredicate) {
-      return new AndSource(all);
+      return new AndChangeSource(all);
     } else if (in instanceof OrPredicate) {
       return new OrSource(all);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index d389ccf..4a331d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -20,12 +20,12 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -180,16 +180,16 @@
     for (Index<?, ChangeData> i : getWriteIndexes()) {
       i.replace(cd);
     }
-    fireChangeIndexedEvent(cd);
+    fireChangeIndexedEvent(cd.getId().get());
   }
 
-  private void fireChangeIndexedEvent(ChangeData change) {
+  private void fireChangeIndexedEvent(int id) {
     for (ChangeIndexedListener listener : indexedListener) {
-      listener.onChangeIndexed(change);
+      listener.onChangeIndexed(id);
     }
   }
 
-  private void fireChangeDeletedFromIndexEvent(Change.Id id) {
+  private void fireChangeDeletedFromIndexEvent(int id) {
     for (ChangeIndexedListener listener : indexedListener) {
       listener.onChangeDeleted(id);
     }
@@ -204,9 +204,7 @@
   public void index(ReviewDb db, Change change)
       throws IOException, OrmException {
     ChangeData cd;
-    if (notesMigration.readChanges()) {
-      cd = changeDataFactory.create(db, change);
-    } else if (notesMigration.writeChanges()) {
+    if (notesMigration.commitChangeWrites()) {
       // Auto-rebuilding when NoteDb reads are disabled just increases
       // contention on the meta ref from a background indexing thread with
       // little benefit. The next actual write to the entity may still incur a
@@ -340,7 +338,7 @@
       for (ChangeIndex i : getWriteIndexes()) {
         i.delete(id);
       }
-      fireChangeDeletedFromIndexEvent(id);
+      fireChangeDeletedFromIndexEvent(id.get());
       return null;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 0e30d6b..996caa7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -14,23 +14,33 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.primitives.Ints;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.IndexedQuery;
 import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.server.query.Matchable;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
 
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -42,7 +52,7 @@
  * {@link ChangeDataSource} to be chosen by the query processor.
  */
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
-    implements ChangeDataSource {
+    implements ChangeDataSource, Matchable<ChangeData> {
   public static QueryOptions oneResult() {
     return createOptions(IndexConfig.createDefault(), 0, 1,
         ImmutableSet.<String> of());
@@ -61,17 +71,73 @@
 
   @VisibleForTesting
   static QueryOptions convertOptions(QueryOptions opts) {
-    // Increase the limit rather than skipping, since we don't know how many
-    // skipped results would have been filtered out by the enclosing AndSource.
-    int backendLimit = opts.config().maxLimit();
-    int limit = Ints.saturatedCast((long) opts.limit() + opts.start());
-    limit = Math.min(limit, backendLimit);
-    return IndexedChangeQuery.createOptions(opts.config(), 0, limit, opts.fields());
+    opts = opts.convertForBackend();
+    return IndexedChangeQuery.createOptions(opts.config(), opts.start(),
+        opts.limit(), opts.fields());
   }
 
+  private final Map<ChangeData, DataSource<ChangeData>> fromSource;
+
   public IndexedChangeQuery(ChangeIndex index, Predicate<ChangeData> pred,
       QueryOptions opts) throws QueryParseException {
     super(index, pred, convertOptions(opts));
+    this.fromSource = new HashMap<>();
+  }
+
+  @Override
+  public ResultSet<ChangeData> read() throws OrmException {
+    final DataSource<ChangeData> currSource = source;
+    final ResultSet<ChangeData> rs = currSource.read();
+
+    return new ResultSet<ChangeData>() {
+      @Override
+      public Iterator<ChangeData> iterator() {
+        return Iterables.transform(
+            rs,
+            new Function<ChangeData, ChangeData>() {
+              @Override
+              public ChangeData apply(ChangeData cd) {
+                fromSource.put(cd, currSource);
+                return cd;
+              }
+            }).iterator();
+      }
+
+      @Override
+      public List<ChangeData> toList() {
+        List<ChangeData> r = rs.toList();
+        for (ChangeData cd : r) {
+          fromSource.put(cd, currSource);
+        }
+        return r;
+      }
+
+      @Override
+      public void close() {
+        rs.close();
+      }
+    };
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    if (source != null && fromSource.get(cd) == source) {
+      return true;
+    }
+
+    Predicate<ChangeData> pred = getChild(0);
+    checkState(pred.isMatchable(),
+        "match invoked, but child predicate %s " + "doesn't implement %s", pred,
+        Matchable.class.getName());
+    return pred.asMatchable().match(cd);
+  }
+
+  @Override
+  public int getCost() {
+    // Index queries are assumed to be cheaper than any other type of query, so
+    // so try to make sure they get picked. Note that pred's cost may be higher
+    // because it doesn't know whether it's being used in an index query or not.
+    return 1;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
index 5db8f64..e446f9a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
@@ -151,10 +151,14 @@
         throws OrmException, IOException, NoSuchChangeException {
       // Reload change, as some time may have passed since GetChanges.
       ReviewDb db = ctx.getReviewDbProvider().get();
-      Change c = notesFactory
-          .createChecked(db, new Project.NameKey(event.getProjectName()), id)
-          .getChange();
-      indexerFactory.create(executor, indexes).index(db, c);
+      try {
+        Change c = notesFactory
+            .createChecked(db, new Project.NameKey(event.getProjectName()), id)
+            .getChange();
+        indexerFactory.create(executor, indexes).index(db, c);
+      } catch (NoSuchChangeException e) {
+        indexerFactory.create(executor, indexes).delete(id);
+      }
       return null;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
index 8a80bfe..6e4e1d4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/ProjectWatch.java
@@ -211,6 +211,6 @@
         p = Predicate.and(filterPredicate, p);
       }
     }
-    return p == null || p.match(changeData);
+    return p == null || p.asMatchable().match(changeData);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 2b2ce63..70a5f4f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -192,6 +192,11 @@
     if (isEmpty()) {
       return null;
     }
+
+    // Allow this method to proceed even if migration.failChangeWrites() = true.
+    // This may be used by an auto-rebuilding step that the caller does not plan
+    // to actually store.
+
     checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
     ObjectId z = ObjectId.zeroId();
     CommitBuilder cb = applyImpl(rw, ins, curr);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index ad31575..82ed02a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -34,11 +34,6 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
-import com.google.common.util.concurrent.AsyncFunction;
-import com.google.common.util.concurrent.CheckedFuture;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.metrics.Timer1;
@@ -52,6 +47,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.git.RefCache;
 import com.google.gerrit.server.git.RepoRefCache;
@@ -59,7 +55,6 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -80,7 +75,6 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.concurrent.Callable;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
@@ -128,20 +122,16 @@
 
     public ChangeNotes createChecked(ReviewDb db, Change c)
         throws OrmException, NoSuchChangeException {
-      ChangeNotes notes = create(db, c.getProject(), c.getId());
-      if (notes.getChange() == null) {
-        throw new NoSuchChangeException(c.getId());
-      }
-      return notes;
+      return createChecked(db, c.getProject(), c.getId());
     }
 
     public ChangeNotes createChecked(ReviewDb db, Project.NameKey project,
         Change.Id changeId) throws OrmException, NoSuchChangeException {
-      ChangeNotes notes = create(db, project, changeId);
-      if (notes.getChange() == null) {
+      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
+      if (change == null || !change.getProject().equals(project)) {
         throw new NoSuchChangeException(changeId);
       }
-      return notes;
+      return new ChangeNotes(args, change).load();
     }
 
     public ChangeNotes createChecked(Change.Id changeId)
@@ -161,7 +151,7 @@
 
     public ChangeNotes create(ReviewDb db, Project.NameKey project,
         Change.Id changeId) throws OrmException {
-      Change change = unwrap(db).changes().get(changeId);
+      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
       checkNotNull(change,
           "change %s not found in ReviewDb", changeId);
       checkArgument(change.getProject().equals(project),
@@ -194,7 +184,7 @@
         ReviewDb db, Change.Id changeId) throws OrmException {
       checkState(!args.migration.readChanges(), "do not call"
           + " createFromIdOnlyWhenNoteDbDisabled when NoteDb is enabled");
-      Change change = unwrap(db).changes().get(changeId);
+      Change change = ReviewDbUtil.unwrapDb(db).changes().get(changeId);
       checkNotNull(change,
           "change %s not found in ReviewDb", changeId);
       return new ChangeNotes(args, change).load();
@@ -217,37 +207,6 @@
       return new ChangeNotes(args, change).load();
     }
 
-    public CheckedFuture<ChangeNotes, OrmException> createAsync(
-        final ListeningExecutorService executorService, final ReviewDb db,
-        final Project.NameKey project, final Change.Id changeId) {
-      return Futures.makeChecked(
-          Futures.transformAsync(unwrap(db).changes().getAsync(changeId),
-              new AsyncFunction<Change, ChangeNotes>() {
-                @Override
-                public ListenableFuture<ChangeNotes> apply(
-                    final Change change) {
-                  return executorService.submit(new Callable<ChangeNotes>() {
-                    @Override
-                    public ChangeNotes call() throws Exception {
-                      checkArgument(change.getProject().equals(project),
-                          "passed project %s when creating ChangeNotes for %s,"
-                              + " but actual project is %s",
-                          project, changeId, change.getProject());
-                      return new ChangeNotes(args, change).load();
-                    }
-                  });
-                }
-              }), new Function<Exception, OrmException>() {
-                @Override
-                public OrmException apply(Exception e) {
-                  if (e instanceof OrmException) {
-                    return (OrmException) e;
-                  }
-                  return new OrmException(e);
-                }
-              });
-    }
-
     public List<ChangeNotes> create(ReviewDb db,
         Collection<Change.Id> changeIds) throws OrmException {
       List<ChangeNotes> notes = new ArrayList<>();
@@ -262,7 +221,7 @@
         return notes;
       }
 
-      for (Change c : unwrap(db).changes().get(changeIds)) {
+      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
         notes.add(createFromChangeOnlyWhenNoteDbDisabled(c));
       }
       return notes;
@@ -282,7 +241,7 @@
         return notes;
       }
 
-      for (Change c : unwrap(db).changes().get(changeIds)) {
+      for (Change c : ReviewDbUtil.unwrapDb(db).changes().get(changeIds)) {
         if (c != null && project.equals(c.getDest().getParentKey())) {
           ChangeNotes cn = createFromChangeOnlyWhenNoteDbDisabled(c);
           if (predicate.apply(cn)) {
@@ -308,7 +267,7 @@
           }
         }
       } else {
-        for (Change change : unwrap(db).changes().all()) {
+        for (Change change : ReviewDbUtil.unwrapDb(db).changes().all()) {
           ChangeNotes notes = createFromChangeOnlyWhenNoteDbDisabled(change);
           if (predicate.apply(notes)) {
             m.put(change.getProject(), notes);
@@ -334,7 +293,7 @@
       // A batch size of N may overload get(Iterable), so use something smaller,
       // but still >1.
       for (List<Change.Id> batch : Iterables.partition(ids, 30)) {
-        for (Change change : unwrap(db).changes().get(batch)) {
+        for (Change change : ReviewDbUtil.unwrapDb(db).changes().get(batch)) {
           notes.add(createFromChangeOnlyWhenNoteDbDisabled(change));
         }
       }
@@ -345,7 +304,7 @@
         Project.NameKey project) throws OrmException, IOException {
       Set<Change.Id> ids = scan(repo);
       List<ChangeNotes> changeNotes = new ArrayList<>(ids.size());
-      db = unwrap(db);
+      db = ReviewDbUtil.unwrapDb(db);
       for (Change.Id id : ids) {
         Change change = db.changes().get(id);
         if (change == null) {
@@ -381,13 +340,6 @@
     }
   }
 
-  private static ReviewDb unwrap(ReviewDb db) {
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
-  }
-
   private final RefCache refs;
 
   private Change change;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
index c1c3859..a181431 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRebuilderImpl.java
@@ -61,7 +61,6 @@
 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.schema.DisabledChangesReviewDbWrapper;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
@@ -122,6 +121,7 @@
   private final ChangeNoteUtil changeNoteUtil;
   private final ChangeUpdate.Factory updateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final NotesMigration migration;
   private final PatchListCache patchListCache;
   private final PersonIdent serverIdent;
   private final ProjectCache projectCache;
@@ -134,6 +134,7 @@
       ChangeNoteUtil changeNoteUtil,
       ChangeUpdate.Factory updateFactory,
       NoteDbUpdateManager.Factory updateManagerFactory,
+      NotesMigration migration,
       PatchListCache patchListCache,
       @GerritPersonIdent PersonIdent serverIdent,
       @Nullable ProjectCache projectCache,
@@ -144,6 +145,7 @@
     this.changeNoteUtil = changeNoteUtil;
     this.updateFactory = updateFactory;
     this.updateManagerFactory = updateManagerFactory;
+    this.migration = migration;
     this.patchListCache = patchListCache;
     this.serverIdent = serverIdent;
     this.projectCache = projectCache;
@@ -154,7 +156,7 @@
   public Result rebuild(ReviewDb db, Change.Id changeId)
       throws NoSuchChangeException, IOException, OrmException,
       ConfigInvalidException {
-    db = unwrapDb(db);
+    db = ReviewDbUtil.unwrapDb(db);
     Change change = db.changes().get(changeId);
     if (change == null) {
       throw new NoSuchChangeException(changeId);
@@ -185,7 +187,7 @@
   @Override
   public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
       throws NoSuchChangeException, IOException, OrmException {
-    db = unwrapDb(db);
+    db = ReviewDbUtil.unwrapDb(db);
     Change change = db.changes().get(changeId);
     if (change == null) {
       throw new NoSuchChangeException(changeId);
@@ -201,7 +203,7 @@
   public Result execute(ReviewDb db, Change.Id changeId,
       NoteDbUpdateManager manager) throws NoSuchChangeException, OrmException,
       IOException {
-    db = unwrapDb(db);
+    db = ReviewDbUtil.unwrapDb(db);
     Change change = db.changes().get(changeId);
     if (change == null) {
       throw new NoSuchChangeException(changeId);
@@ -221,7 +223,14 @@
           return change;
         }
       });
-      manager.execute();
+      if (!migration.failChangeWrites()) {
+        manager.execute();
+      } else {
+        // Don't even attempt to execute if read-only, it would fail anyway. But
+        // do throw an exception to the caller so they know to use the staged
+        // results instead of reading from the repo.
+        throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
+      }
     } catch (AbortUpdateException e) {
       // Drop this rebuild; another thread completed it.
     }
@@ -1011,11 +1020,4 @@
       }
     }
   }
-
-  private ReviewDb unwrapDb(ReviewDb db) {
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
index 0d055cb..c13ccb2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
@@ -64,10 +64,6 @@
         checkArgument(lk.equals(WRITE) || lk.equals(READ),
             "invalid NoteDb key: %s.%s", t, key);
       }
-      boolean write = cfg.getBoolean(NOTE_DB, t, WRITE, false);
-      boolean read = cfg.getBoolean(NOTE_DB, t, READ, false);
-      checkArgument(!(read && !write),
-          "must have write enabled when read enabled: %s", t);
     }
   }
 
@@ -95,7 +91,7 @@
   }
 
   @Override
-  public boolean writeChanges() {
+  protected boolean writeChanges() {
     return writeChanges;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 1f34998..c54c17d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -70,6 +70,8 @@
  * of updates, use {@link #stage()}.
  */
 public class NoteDbUpdateManager {
+  public static String CHANGES_READ_ONLY = "NoteDb changes are read-only";
+
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
   }
@@ -249,7 +251,7 @@
   }
 
   private boolean isEmpty() {
-    if (!migration.writeChanges()) {
+    if (!migration.commitChangeWrites()) {
       return true;
     }
     return changeUpdates.isEmpty()
@@ -382,6 +384,10 @@
   }
 
   public void execute() throws OrmException, IOException {
+    // Check before even inspecting the list, as this is a programmer error.
+    if (migration.failChangeWrites()) {
+      throw new OrmException(CHANGES_READ_ONLY);
+    }
     if (isEmpty()) {
       return;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
index 097fdf5..4bc1407 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -34,9 +34,33 @@
  * these reasons, the options remain undocumented.
  */
 public abstract class NotesMigration {
+  /**
+   * Read changes from NoteDb.
+   * <p>
+   * Change data is read from NoteDb refs, but ReviewDb is still the source of
+   * truth. If the loader determines NoteDb is out of date, the change data in
+   * NoteDb will be transparently rebuilt. This means that some code paths that
+   * look read-only may in fact attempt to write.
+   * <p>
+   * If true and {@code writeChanges() = false}, changes can still be read from
+   * NoteDb, but any attempts to write will generate an error.
+   */
   public abstract boolean readChanges();
 
-  public abstract boolean writeChanges();
+  /**
+   * Write changes to NoteDb.
+   * <p>
+   * Updates to change data are written to NoteDb refs, but ReviewDb is still
+   * the source of truth. Change data will not be written unless the NoteDb refs
+   * are already up to date, and the write path will attempt to rebuild the
+   * change if not.
+   * <p>
+   * If false, the behavior when attempting to write depends on
+   * {@code readChanges()}. If {@code readChanges() = false}, writes to NoteDb
+   * are simply ignored; if {@code true}, any attempts to write will generate an
+   * error.
+   */
+  protected abstract boolean writeChanges();
 
   public abstract boolean readAccounts();
 
@@ -51,6 +75,24 @@
     return false;
   }
 
+  public boolean commitChangeWrites() {
+    // It may seem odd that readChanges() without writeChanges() means we should
+    // attempt to commit writes. However, this method is used by callers to know
+    // whether or not they should short-circuit and skip attempting to read or
+    // write NoteDb refs.
+    //
+    // It is possible for commitChangeWrites() to return true and
+    // failChangeWrites() to also return true, causing an error later in the
+    // same codepath. This specific condition is used by the auto-rebuilding
+    // path to rebuild a change and stage the results, but not commit them due
+    // to failChangeWrites().
+    return writeChanges() || readChanges();
+  }
+
+  public boolean failChangeWrites() {
+    return !writeChanges() && readChanges();
+  }
+
   public boolean enabled() {
     return writeChanges() || readChanges()
         || writeAccounts() || readAccounts();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
index 39b0fa3..899e789 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gwtorm.server.OrmException;
 
 import java.util.ArrayList;
@@ -23,7 +25,7 @@
 import java.util.List;
 
 /** Requires all predicates to be true. */
-public class AndPredicate<T> extends Predicate<T> {
+public class AndPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final List<Predicate<T>> children;
   private final int cost;
 
@@ -39,11 +41,11 @@
       if (getClass() == p.getClass()) {
         for (Predicate<T> gp : p.getChildren()) {
           t.add(gp);
-          c += gp.getCost();
+          c += gp.estimateCost();
         }
       } else {
         t.add(p);
-        c += p.getCost();
+        c += p.estimateCost();
       }
     }
     children = t;
@@ -71,9 +73,21 @@
   }
 
   @Override
+  public boolean isMatchable() {
+    for (Predicate<T> c : children) {
+      if (!c.isMatchable()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @Override
   public boolean match(final T object) throws OrmException {
-    for (final Predicate<T> c : children) {
-      if (!c.match(object)) {
+    for (Predicate<T> c : children) {
+      checkState(c.isMatchable(), "match invoked, but child predicate %s "
+          + "doesn't implement %s", c, Matchable.class.getName());
+      if (!c.asMatchable().match(object)) {
         return false;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
new file mode 100644
index 0000000..168be5d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2016 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.query;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Function;
+import com.google.common.base.Throwables;
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gwtorm.server.ListResultSet;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.ResultSet;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+public class AndSource<T> extends AndPredicate<T>
+    implements DataSource<T>, Comparator<Predicate<T>> {
+  protected final DataSource<T> source;
+
+  private final IsVisibleToPredicate<T> isVisibleToPredicate;
+  private final int start;
+  private final int cardinality;
+
+  public AndSource(Collection<? extends Predicate<T>> that) {
+    this(that, null, 0);
+  }
+
+  public AndSource(Predicate<T> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate) {
+    this(that, isVisibleToPredicate, 0);
+  }
+
+  public AndSource(Predicate<T> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
+    this(ImmutableList.of(that), isVisibleToPredicate, start);
+  }
+
+  public AndSource(Collection<? extends Predicate<T>> that,
+      IsVisibleToPredicate<T> isVisibleToPredicate, int start) {
+    super(that);
+    checkArgument(start >= 0, "negative start: %s", start);
+    this.isVisibleToPredicate = isVisibleToPredicate;
+    this.start = start;
+
+    int c = Integer.MAX_VALUE;
+    DataSource<T> s = null;
+    int minCost = Integer.MAX_VALUE;
+    for (Predicate<T> p : sort(getChildren())) {
+      if (p instanceof DataSource) {
+        c = Math.min(c, ((DataSource<?>) p).getCardinality());
+
+        int cost = p.estimateCost();
+        if (cost < minCost) {
+          s = toDataSource(p);
+          minCost = cost;
+        }
+      }
+    }
+    this.source = s;
+    this.cardinality = c;
+  }
+
+  @Override
+  public ResultSet<T> read() throws OrmException {
+    try {
+      return readImpl();
+    } catch (OrmRuntimeException err) {
+      Throwables.propagateIfInstanceOf(err.getCause(), OrmException.class);
+      throw new OrmException(err);
+    }
+  }
+
+  private ResultSet<T> readImpl() throws OrmException {
+    if (source == null) {
+      throw new OrmException("No DataSource: " + this);
+    }
+    List<T> r = new ArrayList<>();
+    T last = null;
+    int nextStart = 0;
+    boolean skipped = false;
+    for (T data : buffer(source.read())) {
+      if (!isMatchable() || match(data)) {
+        r.add(data);
+      } else {
+        skipped = true;
+      }
+      last = data;
+      nextStart++;
+    }
+
+    if (skipped && last != null && source instanceof Paginated) {
+      // If our source is a paginated source and we skipped at
+      // least one of its results, we may not have filled the full
+      // limit the caller wants.  Restart the source and continue.
+      //
+      @SuppressWarnings("unchecked")
+      Paginated<T> p = (Paginated<T>) source;
+      while (skipped && r.size() < p.getOptions().limit() + start) {
+        skipped = false;
+        ResultSet<T> next = p.restart(nextStart);
+
+        for (T data : buffer(next)) {
+          if (match(data)) {
+            r.add(data);
+          } else {
+            skipped = true;
+          }
+          nextStart++;
+        }
+      }
+    }
+
+    if (start >= r.size()) {
+      r = ImmutableList.of();
+    } else if (start > 0) {
+      r = ImmutableList.copyOf(r.subList(start, r.size()));
+    }
+    return new ListResultSet<>(r);
+  }
+
+  @Override
+  public boolean isMatchable() {
+    return isVisibleToPredicate != null || super.isMatchable();
+  }
+
+  @Override
+  public boolean match(T object) throws OrmException {
+    if (isVisibleToPredicate != null && !isVisibleToPredicate.match(object)) {
+      return false;
+    }
+
+    if (super.isMatchable() && !super.match(object)) {
+      return false;
+    }
+
+    return true;
+  }
+
+  private Iterable<T> buffer(ResultSet<T> scanner) {
+    return FluentIterable.from(Iterables.partition(scanner, 50))
+        .transformAndConcat(new Function<List<T>, List<T>>() {
+          @Override
+          public List<T> apply(List<T> buffer) {
+            return transformBuffer(buffer);
+          }
+        });
+  }
+
+  protected List<T> transformBuffer(List<T> buffer) throws OrmRuntimeException {
+    return buffer;
+  }
+
+  @Override
+  public int getCardinality() {
+    return cardinality;
+  }
+
+  private List<Predicate<T>> sort(Collection<? extends Predicate<T>> that) {
+    List<Predicate<T>> r = new ArrayList<>(that);
+    Collections.sort(r, this);
+    return r;
+  }
+
+  @Override
+  public int compare(Predicate<T> a, Predicate<T> b) {
+    int ai = a instanceof DataSource ? 0 : 1;
+    int bi = b instanceof DataSource ? 0 : 1;
+    int cmp = ai - bi;
+
+    if (cmp == 0) {
+      cmp = a.estimateCost() - b.estimateCost();
+    }
+
+    if (cmp == 0
+        && a instanceof DataSource
+        && b instanceof DataSource) {
+      DataSource<?> as = (DataSource<?>) a;
+      DataSource<?> bs = (DataSource<?>) b;
+      cmp = as.getCardinality() - bs.getCardinality();
+    }
+    return cmp;
+  }
+
+  @SuppressWarnings("unchecked")
+  private DataSource<T> toDataSource(Predicate<T> pred) {
+    return (DataSource<T>) pred;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
new file mode 100644
index 0000000..38411e3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 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.query;
+
+public abstract class IsVisibleToPredicate<T> extends OperatorPredicate<T>
+    implements Matchable<T> {
+  public IsVisibleToPredicate(String name, String value) {
+    super(name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
index d4e7440..7c38e5a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query;
 
-public class LimitPredicate<T> extends IntPredicate<T> {
+public class LimitPredicate<T> extends IntPredicate<T> implements Matchable<T> {
   @SuppressWarnings("unchecked")
   public static Integer getLimit(String fieldName, Predicate<?> p) {
     IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java
new file mode 100644
index 0000000..b37e112
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 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.query;
+
+import com.google.gwtorm.server.OrmException;
+
+public interface Matchable<T> {
+  /**
+   * Does this predicate match this object?
+   *
+   * @throws OrmException
+   */
+  boolean match(T object) throws OrmException;
+
+  /** @return a cost estimate to run this predicate, higher figures cost more. */
+  int getCost();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
index 248fb9c..8ffba72 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Collection;
@@ -21,7 +23,7 @@
 import java.util.List;
 
 /** Negates the result of another predicate. */
-public class NotPredicate<T> extends Predicate<T> {
+public class NotPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final Predicate<T> that;
 
   protected NotPredicate(final Predicate<T> that) {
@@ -58,13 +60,20 @@
   }
 
   @Override
+  public boolean isMatchable() {
+    return that.isMatchable();
+  }
+
+  @Override
   public boolean match(final T object) throws OrmException {
-    return !that.match(object);
+    checkState(that.isMatchable(), "match invoked, but child predicate %s "
+        + "doesn't implement %s", that, Matchable.class.getName());
+    return !that.asMatchable().match(object);
   }
 
   @Override
   public int getCost() {
-    return that.getCost();
+    return that.estimateCost();
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
index e9fea02..2cb70af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
@@ -22,7 +22,7 @@
   private final String name;
   private final String value;
 
-  public OperatorPredicate(final String name, final String value) {
+  protected OperatorPredicate(final String name, final String value) {
     this.name = name;
     this.value = value;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
index 2432a41..ad15286 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.gwtorm.server.OrmException;
 
 import java.util.ArrayList;
@@ -23,7 +25,7 @@
 import java.util.List;
 
 /** Requires one predicate to be true. */
-public class OrPredicate<T> extends Predicate<T> {
+public class OrPredicate<T> extends Predicate<T> implements Matchable<T> {
   private final List<Predicate<T>> children;
   private final int cost;
 
@@ -39,11 +41,11 @@
       if (getClass() == p.getClass()) {
         for (Predicate<T> gp : p.getChildren()) {
           t.add(gp);
-          c += gp.getCost();
+          c += gp.estimateCost();
         }
       } else {
         t.add(p);
-        c += p.getCost();
+        c += p.estimateCost();
       }
     }
     children = t;
@@ -71,9 +73,21 @@
   }
 
   @Override
+  public boolean isMatchable() {
+    for (Predicate<T> c : children) {
+      if (!c.isMatchable()) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  @Override
   public boolean match(final T object) throws OrmException {
     for (final Predicate<T> c : children) {
-      if (c.match(object)) {
+      checkState(c.isMatchable(), "match invoked, but child predicate %s "
+          + "doesn't implement %s", c, Matchable.class.getName());
+      if (c.asMatchable().match(object)) {
         return true;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
index f4be013..3a38da6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.query;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.collect.Iterables;
-import com.google.gwtorm.server.OrmException;
 
 import java.util.Collection;
 import java.util.Collections;
@@ -113,15 +114,23 @@
   /** Create a copy of this predicate, with new children. */
   public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
 
-  /**
-   * Does this predicate match this object?
-   *
-   * @throws OrmException
-   */
-  public abstract boolean match(T object) throws OrmException;
+  public boolean isMatchable() {
+    return this instanceof Matchable;
+  }
+
+  @SuppressWarnings("unchecked")
+  public Matchable<T> asMatchable() {
+    checkState(isMatchable(), "not matchable");
+    return (Matchable<T>) this;
+  }
 
   /** @return a cost estimate to run this predicate, higher figures cost more. */
-  public abstract int getCost();
+  public int estimateCost() {
+    if (!isMatchable()) {
+      return 1;
+    }
+    return asMatchable().getCost();
+  }
 
   @Override
   public abstract int hashCode();
@@ -129,7 +138,7 @@
   @Override
   public abstract boolean equals(Object other);
 
-  private static class Any<T> extends Predicate<T> {
+  private static class Any<T> extends Predicate<T> implements Matchable<T> {
     private static final Any<Object> INSTANCE = new Any<>();
 
     private Any() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
index 3334117..d971d86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
@@ -18,10 +18,12 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Ordering;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer1;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.index.IndexConfig;
@@ -31,6 +33,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
 import java.util.ArrayList;
@@ -54,24 +57,30 @@
     }
   }
 
-  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+  protected final Provider<CurrentUser> userProvider;
+
   private final Metrics metrics;
   private final SchemaDefinitions<T> schemaDef;
   private final IndexConfig indexConfig;
+  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
   private final IndexRewriter<T> rewriter;
   private final String limitField;
 
   protected int start;
 
+  private boolean enforceVisibility = true;
   private int limitFromCaller;
   private Set<String> requestedFields;
 
-  protected QueryProcessor(Metrics metrics,
+  protected QueryProcessor(
+      Provider<CurrentUser> userProvider,
+      Metrics metrics,
       SchemaDefinitions<T> schemaDef,
       IndexConfig indexConfig,
       IndexCollection<?, T, ? extends Index<?, T>> indexes,
       IndexRewriter<T> rewriter,
       String limitField) {
+    this.userProvider = userProvider;
     this.metrics = metrics;
     this.schemaDef = schemaDef;
     this.indexConfig = indexConfig;
@@ -85,6 +94,11 @@
     return this;
   }
 
+  public QueryProcessor<T> enforceVisibility(boolean enforce) {
+    enforceVisibility = enforce;
+    return this;
+  }
+
   public QueryProcessor<T> setLimit(int n) {
     limitFromCaller = n;
     return this;
@@ -157,7 +171,10 @@
       // ask for one more result from the query.
       QueryOptions opts =
           createOptions(indexConfig, page, limit, getRequestedFields());
-      Predicate<T> pred = postRewrite(rewriter.rewrite(q, opts));
+      Predicate<T> pred = rewriter.rewrite(q, opts);
+      if (enforceVisibility) {
+        pred = enforceVisibility(pred);
+      }
       predicates.add(pred);
 
       @SuppressWarnings("unchecked")
@@ -192,15 +209,13 @@
   }
 
   /**
-   * Invoked after the query was rewritten. Subclasses may overwrite this method
-   * to add additional predicates to the query before it is executed.
+   * Invoked after the query was rewritten. Subclasses must overwrite this
+   * method to filter out results that are not visible to the calling user.
    *
    * @param pred the query
    * @return the modified query
    */
-  protected Predicate<T> postRewrite(Predicate<T> pred) {
-    return pred;
-  }
+  protected abstract Predicate<T> enforceVisibility(Predicate<T> pred);
 
   private Set<String> getRequestedFields() {
     if (requestedFields != null) {
@@ -216,7 +231,12 @@
     return getPermittedLimit() <= 0;
   }
 
-  protected int getPermittedLimit() {
+  private int getPermittedLimit() {
+    if (enforceVisibility) {
+      return userProvider.get().getCapabilities()
+        .getRange(GlobalCapability.QUERY_LIMIT)
+        .getMax();
+    }
     return Integer.MAX_VALUE;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIdPredicate.java
index 659f5d8..8fda4c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIdPredicate.java
@@ -18,25 +18,10 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.account.AccountField;
-import com.google.gwtorm.server.OrmException;
 
 public class AccountIdPredicate extends IndexPredicate<AccountState> {
-  private final Account.Id accountId;
-
   public AccountIdPredicate(Account.Id accountId) {
     super(AccountField.ID, AccountQueryBuilder.FIELD_ACCOUNT,
         accountId.toString());
-    this.accountId = accountId;
   }
-
-  @Override
-  public boolean match(AccountState accountState) throws OrmException {
-    return accountId.equals(accountState.getAccount().getId());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
new file mode 100644
index 0000000..dc68a61
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 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.query.account;
+
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gwtorm.server.OrmException;
+
+public class AccountIsVisibleToPredicate
+    extends IsVisibleToPredicate<AccountState> {
+  private static String describe(CurrentUser user) {
+    if (user.isIdentifiedUser()) {
+      return user.getAccountId().toString();
+    }
+    if (user instanceof SingleGroupUser) {
+      return "group:" + user.getEffectiveGroups().getKnownGroups() //
+          .iterator().next().toString();
+    }
+    return user.toString();
+  }
+
+  private final AccountControl accountControl;
+
+  AccountIsVisibleToPredicate(AccountControl accountControl) {
+    super(AccountQueryBuilder.FIELD_VISIBLETO,
+        describe(accountControl.getUser()));
+    this.accountControl = accountControl;
+  }
+
+  @Override
+  public boolean match(AccountState accountState) throws OrmException {
+    return accountControl.canSee(accountState);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 63b3030..4082e08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -38,6 +38,7 @@
 
   public static final String FIELD_ACCOUNT = "account";
   public static final String FIELD_LIMIT = "limit";
+  public static final String FIELD_VISIBLETO = "visibleto";
 
   private static final QueryBuilder.Definition<AccountState, AccountQueryBuilder> mydef =
       new QueryBuilder.Definition<>(AccountQueryBuilder.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index 22e9d3d..4819404 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -14,22 +14,49 @@
 
 package com.google.gerrit.server.query.account;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.account.AccountQueryBuilder.FIELD_LIMIT;
 
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.query.AndSource;
+import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryProcessor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 
 public class AccountQueryProcessor extends QueryProcessor<AccountState> {
+  private final AccountControl.Factory accountControlFactory;
 
-  protected AccountQueryProcessor(Metrics metrics,
+  static {
+    // It is assumed that basic rewrites do not touch visibleto predicates.
+    checkState(
+        !AccountIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        "AccountQueryProcessor assumes visibleto is not used by the index rewriter.");
+  }
+
+  @Inject
+  protected AccountQueryProcessor(Provider<CurrentUser> userProvider,
+      Metrics metrics,
       IndexConfig indexConfig,
       AccountIndexCollection indexes,
-      AccountIndexRewriter rewriter) {
-    super(metrics, AccountSchemaDefinitions.INSTANCE, indexConfig, indexes,
-        rewriter, FIELD_LIMIT);
+      AccountIndexRewriter rewriter,
+      AccountControl.Factory accountControlFactory) {
+    super(userProvider, metrics, AccountSchemaDefinitions.INSTANCE, indexConfig,
+        indexes, rewriter, FIELD_LIMIT);
+    this.accountControlFactory = accountControlFactory;
+  }
+
+  @Override
+  protected Predicate<AccountState> enforceVisibility(
+      Predicate<AccountState> pred) {
+    return new AndSource<>(pred,
+        new AccountIsVisibleToPredicate(accountControlFactory.get()));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
index b20a194..241b7fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.IntegerRangePredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-public class AddedPredicate extends IntegerRangePredicate<ChangeData> {
+public class AddedPredicate extends IntegerRangeChangePredicate {
   AddedPredicate(String value) throws QueryParseException {
     super(ChangeField.ADDED, value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 5ed871a..477bf16 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.TimestampRangePredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Date;
 
-public class AfterPredicate extends TimestampRangePredicate<ChangeData> {
+public class AfterPredicate extends TimestampRangeChangePredicate {
   private final Date cut;
 
   AfterPredicate(String value) throws QueryParseException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
index 2b140d3..fd6cbee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -20,13 +20,12 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.index.TimestampRangePredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.sql.Timestamp;
 
-public class AgePredicate extends TimestampRangePredicate<ChangeData> {
+public class AgePredicate extends TimestampRangeChangePredicate {
   private final long cut;
 
   AgePredicate(String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
new file mode 100644
index 0000000..bd7daed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -0,0 +1,68 @@
+// Copyright (C) 2010 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.query.change;
+
+import com.google.gerrit.server.query.AndSource;
+import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+
+import java.util.Collection;
+import java.util.List;
+
+public class AndChangeSource extends AndSource<ChangeData>
+    implements ChangeDataSource {
+
+  public AndChangeSource(Collection<Predicate<ChangeData>> that) {
+    super(that);
+  }
+
+  public AndChangeSource(Predicate<ChangeData> that,
+      IsVisibleToPredicate<ChangeData> isVisibleToPredicate, int start) {
+    super(that, isVisibleToPredicate, start);
+  }
+
+  @Override
+  public boolean hasChange() {
+    return source != null && source instanceof ChangeDataSource
+        && ((ChangeDataSource) source).hasChange();
+  }
+
+  @Override
+  protected List<ChangeData> transformBuffer(List<ChangeData> buffer)
+      throws OrmRuntimeException {
+    if (!hasChange()) {
+      try {
+        ChangeData.ensureChangeLoaded(buffer);
+      } catch (OrmException e) {
+        throw new OrmRuntimeException(e);
+      }
+    }
+    return super.transformBuffer(buffer);
+  }
+
+  @Override
+  public int compare(Predicate<ChangeData> a, Predicate<ChangeData> b) {
+    int cmp = super.compare(a, b);
+    if (cmp == 0 && a instanceof ChangeDataSource
+        && b instanceof ChangeDataSource) {
+      ChangeDataSource as = (ChangeDataSource) a;
+      ChangeDataSource bs = (ChangeDataSource) b;
+      cmp = (as.hasChange() ? 0 : 1) - (bs.hasChange() ? 0 : 1);
+    }
+    return cmp;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
deleted file mode 100644
index 75847c3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndSource.java
+++ /dev/null
@@ -1,201 +0,0 @@
-// Copyright (C) 2010 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.query.change;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.Function;
-import com.google.common.base.Throwables;
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.Paginated;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gwtorm.server.ListResultSet;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-
-public class AndSource extends AndPredicate<ChangeData>
-    implements ChangeDataSource {
-  private static final Comparator<Predicate<ChangeData>> CMP =
-      new Comparator<Predicate<ChangeData>>() {
-        @Override
-        public int compare(Predicate<ChangeData> a, Predicate<ChangeData> b) {
-          int ai = a instanceof ChangeDataSource ? 0 : 1;
-          int bi = b instanceof ChangeDataSource ? 0 : 1;
-          int cmp = ai - bi;
-
-          if (cmp == 0) {
-            cmp = a.getCost() - b.getCost();
-          }
-
-          if (cmp == 0 //
-              && a instanceof ChangeDataSource //
-              && b instanceof ChangeDataSource) {
-            ChangeDataSource as = (ChangeDataSource) a;
-            ChangeDataSource bs = (ChangeDataSource) b;
-            cmp = as.getCardinality() - bs.getCardinality();
-
-            if (cmp == 0) {
-              cmp = (as.hasChange() ? 0 : 1)
-                  - (bs.hasChange() ? 0 : 1);
-            }
-          }
-
-          return cmp;
-        }
-      };
-
-  private static List<Predicate<ChangeData>> sort(
-      Collection<? extends Predicate<ChangeData>> that) {
-    List<Predicate<ChangeData>> r = new ArrayList<>(that);
-    Collections.sort(r, CMP);
-    return r;
-  }
-
-  private final int start;
-  private int cardinality = -1;
-
-  public AndSource(Collection<? extends Predicate<ChangeData>> that) {
-    this(that, 0);
-  }
-
-  public AndSource(Collection<? extends Predicate<ChangeData>> that,
-      int start) {
-    super(sort(that));
-    checkArgument(start >= 0, "negative start: %s", start);
-    this.start = start;
-  }
-
-  @Override
-  public boolean hasChange() {
-    ChangeDataSource source = source();
-    return source != null && source.hasChange();
-  }
-
-  @Override
-  public ResultSet<ChangeData> read() throws OrmException {
-    try {
-      return readImpl();
-    } catch (OrmRuntimeException err) {
-      Throwables.propagateIfInstanceOf(err.getCause(), OrmException.class);
-      throw new OrmException(err);
-    }
-  }
-
-  private ResultSet<ChangeData> readImpl() throws OrmException {
-    ChangeDataSource source = source();
-    if (source == null) {
-      throw new OrmException("No ChangeDataSource: " + this);
-    }
-    List<ChangeData> r = new ArrayList<>();
-    ChangeData last = null;
-    int nextStart = 0;
-    boolean skipped = false;
-    for (ChangeData data : buffer(source, source.read())) {
-      if (match(data)) {
-        r.add(data);
-      } else {
-        skipped = true;
-      }
-      last = data;
-      nextStart++;
-    }
-
-    if (skipped && last != null && source instanceof Paginated) {
-      // If our source is a paginated source and we skipped at
-      // least one of its results, we may not have filled the full
-      // limit the caller wants.  Restart the source and continue.
-      //
-      @SuppressWarnings("unchecked")
-      Paginated<ChangeData> p = (Paginated<ChangeData>) source;
-      while (skipped && r.size() < p.getOptions().limit() + start) {
-        skipped = false;
-        ResultSet<ChangeData> next = p.restart(nextStart);
-
-        for (ChangeData data : buffer(source, next)) {
-          if (match(data)) {
-            r.add(data);
-          } else {
-            skipped = true;
-          }
-          nextStart++;
-        }
-      }
-    }
-
-    if (start >= r.size()) {
-      r = ImmutableList.of();
-    } else if (start > 0) {
-      r = ImmutableList.copyOf(r.subList(start, r.size()));
-    }
-    return new ListResultSet<>(r);
-  }
-
-  private Iterable<ChangeData> buffer(
-      ChangeDataSource source,
-      ResultSet<ChangeData> scanner) {
-    final boolean loadChange = !source.hasChange();
-    return FluentIterable
-      .from(Iterables.partition(scanner, 50))
-      .transformAndConcat(new Function<List<ChangeData>, List<ChangeData>>() {
-        @Override
-        public List<ChangeData> apply(List<ChangeData> buffer) {
-          if (loadChange) {
-            try {
-              ChangeData.ensureChangeLoaded(buffer);
-            } catch (OrmException e) {
-              throw new OrmRuntimeException(e);
-            }
-          }
-          return buffer;
-        }
-      });
-  }
-
-  private ChangeDataSource source() {
-    int minCost = Integer.MAX_VALUE;
-    Predicate<ChangeData> s = null;
-    for (Predicate<ChangeData> p : getChildren()) {
-      if (p instanceof ChangeDataSource && p.getCost() < minCost) {
-        s = p;
-        minCost = p.getCost();
-      }
-    }
-    return (ChangeDataSource) s;
-  }
-
-  @Override
-  public int getCardinality() {
-    if (cardinality < 0) {
-      cardinality = Integer.MAX_VALUE;
-      for (Predicate<ChangeData> p : getChildren()) {
-        if (p instanceof ChangeDataSource) {
-          int c = ((ChangeDataSource) p).getCardinality();
-          cardinality = Math.min(cardinality, c);
-        }
-      }
-    }
-    return cardinality;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
index ae192ab..ebaaab9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
@@ -17,11 +17,10 @@
 import static com.google.gerrit.server.index.change.ChangeField.AUTHOR;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_AUTHOR;
 
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-public class AuthorPredicate extends IndexPredicate<ChangeData>  {
+public class AuthorPredicate extends ChangeIndexPredicate {
   AuthorPredicate(String value) {
     super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 0618cc2..f36a1631 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.TimestampRangePredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Date;
 
-public class BeforePredicate extends TimestampRangePredicate<ChangeData> {
+public class BeforePredicate extends TimestampRangeChangePredicate {
   private final Date cut;
 
   BeforePredicate(String value) throws QueryParseException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
index 1fbeb86..85d433a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate over Change-Id strings (aka Change.Key). */
-class ChangeIdPredicate extends IndexPredicate<ChangeData> {
+class ChangeIdPredicate extends ChangeIndexPredicate {
   ChangeIdPredicate(String id) {
     super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
new file mode 100644
index 0000000..80951fd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 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.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.query.Matchable;
+
+public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String value) {
+    super(def, value);
+  }
+
+  protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name,
+      String value) {
+    super(def, name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
similarity index 91%
rename from gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsVisibleToPredicate.java
rename to gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c4214f2..303c9f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -20,11 +20,11 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.OperatorPredicate;
+import com.google.gerrit.server.query.IsVisibleToPredicate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class IsVisibleToPredicate extends OperatorPredicate<ChangeData> {
+class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
   private static String describe(CurrentUser user) {
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
@@ -41,7 +41,8 @@
   private final ChangeControl.GenericFactory changeControl;
   private final CurrentUser user;
 
-  IsVisibleToPredicate(Provider<ReviewDb> db, ChangeNotes.Factory notesFactory,
+  ChangeIsVisibleToPredicate(Provider<ReviewDb> db,
+      ChangeNotes.Factory notesFactory,
       ChangeControl.GenericFactory changeControlFactory, CurrentUser user) {
     super(ChangeQueryBuilder.FIELD_VISIBLETO, describe(user));
     this.db = db;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
new file mode 100644
index 0000000..6bec598
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2016 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.query.change;
+
+import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.OperatorPredicate;
+
+public abstract class ChangeOperatorPredicate extends
+    OperatorPredicate<ChangeData> implements Matchable<ChangeData> {
+
+  protected ChangeOperatorPredicate(String name, String value) {
+    super(name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index b99f024..a150c93 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -479,7 +479,7 @@
       // not status: alias?
     }
 
-    throw new IllegalArgumentException();
+    throw error("Invalid query");
   }
 
   @Operator
@@ -768,7 +768,7 @@
   }
 
   public Predicate<ChangeData> visibleto(CurrentUser user) {
-    return new IsVisibleToPredicate(args.db, args.notesFactory,
+    return new ChangeIsVisibleToPredicate(args.db, args.notesFactory,
         args.changeControlGenericFactory, user);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index e4ba721..44e5e7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,8 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.index.IndexConfig;
@@ -39,38 +37,35 @@
 
 public class ChangeQueryProcessor extends QueryProcessor<ChangeData> {
   private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> userProvider;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
 
-  private boolean enforceVisibility = true;
-
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
     checkState(
-        !IsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        !ChangeIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
         "ChangeQueryProcessor assumes visibleto is not used by the index rewriter.");
   }
 
   @Inject
-  ChangeQueryProcessor(Metrics metrics,
+  ChangeQueryProcessor(Provider<CurrentUser> userProvider,
+      Metrics metrics,
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
       Provider<ReviewDb> db,
-      Provider<CurrentUser> userProvider,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory) {
-    super(metrics, ChangeSchemaDefinitions.INSTANCE, indexConfig, indexes,
+    super(userProvider, metrics, ChangeSchemaDefinitions.INSTANCE, indexConfig, indexes,
         rewriter, FIELD_LIMIT);
     this.db = db;
-    this.userProvider = userProvider;
     this.changeControlFactory = changeControlFactory;
     this.notesFactory = notesFactory;
   }
 
+  @Override
   public ChangeQueryProcessor enforceVisibility(boolean enforce) {
-    enforceVisibility = enforce;
+    super.enforceVisibility(enforce);
     return this;
   }
 
@@ -82,22 +77,9 @@
   }
 
   @Override
-  protected Predicate<ChangeData> postRewrite(Predicate<ChangeData> pred) {
-    if (enforceVisibility) {
-      return new AndSource(ImmutableList.of(pred, new IsVisibleToPredicate(db,
-          notesFactory, changeControlFactory, userProvider.get())), start);
-    }
-
-    return super.postRewrite(pred);
-  }
-
-  @Override
-  protected int getPermittedLimit() {
-    if (enforceVisibility) {
-      return userProvider.get().getCapabilities()
-        .getRange(GlobalCapability.QUERY_LIMIT)
-        .getMax();
-    }
-    return Integer.MAX_VALUE;
+  protected Predicate<ChangeData> enforceVisibility(
+      Predicate<ChangeData> pred) {
+    return new AndChangeSource(pred, new ChangeIsVisibleToPredicate(db,
+        notesFactory, changeControlFactory, userProvider.get()), start);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
new file mode 100644
index 0000000..747d72d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2016 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.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.query.Matchable;
+
+public abstract class ChangeRegexPredicate extends RegexPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String value) {
+    super(def, value);
+  }
+
+  protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String name,
+      String value) {
+    super(def, name, value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 4d42c33..1c92ecf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
@@ -36,7 +35,7 @@
  * <p>
  * Status names are looked up by prefix case-insensitively.
  */
-public final class ChangeStatusPredicate extends IndexPredicate<ChangeData> {
+public final class ChangeStatusPredicate extends ChangeIndexPredicate {
   private static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
   private static final Predicate<ChangeData> CLOSED;
   private static final Predicate<ChangeData> OPEN;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 1967a06..48d6e05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -17,13 +17,12 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Objects;
 
-class CommentByPredicate extends IndexPredicate<ChangeData> {
+class CommentByPredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
   CommentByPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
index 5e3fa3d..b351740 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
@@ -22,7 +21,7 @@
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class CommentPredicate extends IndexPredicate<ChangeData> {
+class CommentPredicate extends ChangeIndexPredicate {
   private final ChangeIndex index;
 
   CommentPredicate(ChangeIndex index, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
index 91b2c58..aa3dde3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -20,10 +20,9 @@
 
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gwtorm.server.OrmException;
 
-class CommitPredicate extends IndexPredicate<ChangeData> {
+class CommitPredicate extends ChangeIndexPredicate {
   static FieldDef<ChangeData, ?> commitField(String id) {
     if (id.length() == OBJECT_ID_STRING_LENGTH) {
       return EXACT_COMMIT;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
index f923d00..06f5379 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
@@ -17,11 +17,10 @@
 import static com.google.gerrit.server.index.change.ChangeField.COMMITTER;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_COMMITTER;
 
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-public class CommitterPredicate extends IndexPredicate<ChangeData>  {
+public class CommitterPredicate extends ChangeIndexPredicate {
   CommitterPredicate(String value) {
     super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 74865ec..69bc2ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gerrit.server.query.OrPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
@@ -84,7 +83,7 @@
       predicatesForOneChange.add(or(or(filePredicates),
           new IsMergePredicate(args, value)));
 
-      predicatesForOneChange.add(new OperatorPredicate<ChangeData>(
+      predicatesForOneChange.add(new ChangeOperatorPredicate(
           ChangeQueryBuilder.FIELD_CONFLICTS, value) {
 
         @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index 8e9ac73..9bd6956 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.IntegerRangePredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-public class DeletedPredicate extends IntegerRangePredicate<ChangeData> {
+public class DeletedPredicate extends IntegerRangeChangePredicate {
   DeletedPredicate(String value) throws QueryParseException {
     super(ChangeField.DELETED, value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index a3eaa8a..07e6590 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.IntegerRangePredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
 import com.google.gwtorm.server.OrmException;
 
-public class DeltaPredicate extends IntegerRangePredicate<ChangeData> {
+public class DeltaPredicate extends IntegerRangeChangePredicate {
   DeltaPredicate(String value) throws QueryParseException {
     super(ChangeField.DELTA, value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 25fa09f..7e573dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -16,12 +16,11 @@
 
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Set;
 
-class DestinationPredicate extends OperatorPredicate<ChangeData> {
+class DestinationPredicate extends ChangeOperatorPredicate {
   Set<Branch.NameKey> destinations;
 
   DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
index f1fa000..8be5235 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class EditByPredicate extends IndexPredicate<ChangeData> {
+class EditByPredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
   EditByPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index 8c98b6f..6877761 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -14,13 +14,12 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
-class EqualsFilePredicate extends IndexPredicate<ChangeData> {
+class EqualsFilePredicate extends ChangeIndexPredicate {
   static Predicate<ChangeData> create(Arguments args, String value) {
     Predicate<ChangeData> eqPath =
         new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index b01fdbe..e752b05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
@@ -32,7 +31,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class EqualsLabelPredicate extends IndexPredicate<ChangeData> {
+class EqualsLabelPredicate extends ChangeIndexPredicate {
   private final ProjectCache projectCache;
   private final ChangeControl.GenericFactory ccFactory;
   private final IdentifiedUser.GenericFactory userFactory;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
index 85c3cd5..5edd06c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.Collections;
 import java.util.List;
 
-class EqualsPathPredicate extends IndexPredicate<ChangeData> {
+class EqualsPathPredicate extends ChangeIndexPredicate {
   private final String value;
 
   EqualsPathPredicate(String fieldName, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
index 6658577..510910e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
@@ -17,10 +17,9 @@
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gwtorm.server.OrmException;
 
-class ExactTopicPredicate extends IndexPredicate<ChangeData> {
+class ExactTopicPredicate extends ChangeIndexPredicate {
   ExactTopicPredicate(String topic) {
     super(EXACT_TOPIC, topic);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
index 23b3ee6..5651544 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
@@ -18,14 +18,13 @@
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class FuzzyTopicPredicate extends IndexPredicate<ChangeData> {
+class FuzzyTopicPredicate extends ChangeIndexPredicate {
   private final ChangeIndex index;
 
   FuzzyTopicPredicate(String topic, ChangeIndex index) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
index ff9c544..9e9bc8d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -15,13 +15,12 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.List;
 
-class GroupPredicate extends IndexPredicate<ChangeData> {
+class GroupPredicate extends ChangeIndexPredicate {
   GroupPredicate(String group) {
     super(ChangeField.GROUP, group);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
index f85a9ed..45a00c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByLegacyPredicate.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.ListResultSet;
 import com.google.gwtorm.server.OrmException;
@@ -29,8 +28,8 @@
 import java.util.Set;
 
 @Deprecated
-class HasDraftByLegacyPredicate extends OperatorPredicate<ChangeData> implements
-    ChangeDataSource {
+class HasDraftByLegacyPredicate extends ChangeOperatorPredicate
+    implements ChangeDataSource {
   private final Arguments args;
   private final Account.Id accountId;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
index c18e19c..244589c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HasDraftByPredicate extends IndexPredicate<ChangeData> {
+class HasDraftByPredicate extends ChangeIndexPredicate {
   private final Account.Id accountId;
 
   HasDraftByPredicate(Account.Id accountId) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
index 83990bc..eb3a137 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-public class HasStarsPredicate extends IndexPredicate<ChangeData> {
+public class HasStarsPredicate extends ChangeIndexPredicate {
   private final Account.Id accountId;
 
   HasStarsPredicate(Account.Id accountId) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
index 3f952d3..185a539 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.change.HashtagsUtil;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HashtagPredicate extends IndexPredicate<ChangeData> {
+class HashtagPredicate extends ChangeIndexPredicate {
   HashtagPredicate(String hashtag) {
     super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
new file mode 100644
index 0000000..a272fbb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 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.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.server.query.QueryParseException;
+
+public abstract class IntegerRangeChangePredicate
+    extends IntegerRangePredicate<ChangeData> implements Matchable<ChangeData> {
+
+  protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type,
+      String value) throws QueryParseException {
+    super(type, value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
index 3c02bab..376ad84 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
@@ -26,7 +25,7 @@
 
 import java.io.IOException;
 
-public class IsMergePredicate extends OperatorPredicate<ChangeData> {
+public class IsMergePredicate extends ChangeOperatorPredicate {
   private final Arguments args;
 
   public IsMergePredicate(Arguments args, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
index 5dd7dd2..d998fa3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class IsMergeablePredicate extends IndexPredicate<ChangeData> {
+class IsMergeablePredicate extends ChangeIndexPredicate {
   private final FillArgs args;
 
   IsMergeablePredicate(FillArgs args) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index 604f84b..24fcd6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.server.index.change.ChangeField.REVIEWEDBY;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
@@ -27,7 +26,7 @@
 import java.util.List;
 import java.util.Set;
 
-class IsReviewedPredicate extends IndexPredicate<ChangeData> {
+class IsReviewedPredicate extends ChangeIndexPredicate {
   private static final Account.Id NOT_REVIEWED =
       new Account.Id(ChangeField.NOT_REVIEWED);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
index 634bc4a..929ed18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsStarredByPredicate.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 @Deprecated
-class IsStarredByPredicate extends IndexPredicate<ChangeData> {
+class IsStarredByPredicate extends ChangeIndexPredicate {
   private final Account.Id accountId;
 
   IsStarredByPredicate(Account.Id accountId) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index 656eca0..425eb00 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -17,10 +17,9 @@
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 
 /** Predicate over change number (aka legacy ID or Change.Id). */
-public class LegacyChangeIdPredicate extends IndexPredicate<ChangeData> {
+public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
   private final Change.Id id;
 
   LegacyChangeIdPredicate(Change.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
index db10670..cd93ed3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyReviewerPredicate.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 @Deprecated
-class LegacyReviewerPredicate extends IndexPredicate<ChangeData> {
+class LegacyReviewerPredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
   LegacyReviewerPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 021b6d7..722a8ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
@@ -26,7 +25,7 @@
  * Predicate to match changes that contains specified text in commit messages
  * body.
  */
-class MessagePredicate extends IndexPredicate<ChangeData> {
+class MessagePredicate extends ChangeIndexPredicate {
   private final ChangeIndex index;
 
   MessagePredicate(ChangeIndex index, String value) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
index bb7cb403a..dfaac08 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerPredicate extends IndexPredicate<ChangeData> {
+class OwnerPredicate extends ChangeIndexPredicate {
   private final Account.Id id;
 
   OwnerPredicate(Account.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index 467e4c5..72327ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -17,10 +17,9 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerinPredicate extends OperatorPredicate<ChangeData> {
+class OwnerinPredicate extends ChangeOperatorPredicate {
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index 0bb5650..644870d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPredicate extends IndexPredicate<ChangeData> {
+class ProjectPredicate extends ChangeIndexPredicate {
   ProjectPredicate(String id) {
     super(ChangeField.PROJECT, id);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index 400a204..4c06d1b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPrefixPredicate extends IndexPredicate<ChangeData> {
+class ProjectPrefixPredicate extends ChangeIndexPredicate {
   ProjectPrefixPredicate(String prefix) {
     super(ChangeField.PROJECTS, prefix);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
index e62855f..491aed9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class RefPredicate extends IndexPredicate<ChangeData> {
+class RefPredicate extends ChangeIndexPredicate {
   RefPredicate(String ref) {
     super(ChangeField.REF, ref);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index d1e0f02..67efd69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -14,14 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.RegexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.List;
 
-class RegexPathPredicate extends RegexPredicate<ChangeData> {
+class RegexPathPredicate extends ChangeRegexPredicate {
   RegexPathPredicate(String re) {
     super(ChangeField.PATH, re);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 48c815f..007566e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -16,14 +16,13 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.index.RegexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexProjectPredicate extends RegexPredicate<ChangeData> {
+class RegexProjectPredicate extends ChangeRegexPredicate {
   private final RunAutomaton pattern;
 
   RegexProjectPredicate(String re) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index 00c1dfe..c6d1577 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -15,14 +15,13 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.RegexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexRefPredicate extends RegexPredicate<ChangeData> {
+class RegexRefPredicate extends ChangeRegexPredicate {
   private final RunAutomaton pattern;
 
   RegexRefPredicate(String re) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index d51aaa4..2d65670 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -17,13 +17,12 @@
 import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.RegexPredicate;
 import com.google.gwtorm.server.OrmException;
 
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexTopicPredicate extends RegexPredicate<ChangeData> {
+class RegexTopicPredicate extends ChangeRegexPredicate {
   private final RunAutomaton pattern;
 
   RegexTopicPredicate(String re) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index d655a5f..1c4fbbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.Predicate;
@@ -26,7 +25,7 @@
 import java.util.ArrayList;
 import java.util.List;
 
-class ReviewerPredicate extends IndexPredicate<ChangeData> {
+class ReviewerPredicate extends ChangeIndexPredicate {
   @SuppressWarnings("deprecation")
   static Predicate<ChangeData> create(Arguments args, Account.Id id) {
     List<Predicate<ChangeData>> and = new ArrayList<>(2);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index 76a02432..34c10e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -17,10 +17,9 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.query.OperatorPredicate;
 import com.google.gwtorm.server.OrmException;
 
-class ReviewerinPredicate extends OperatorPredicate<ChangeData> {
+class ReviewerinPredicate extends ChangeOperatorPredicate {
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountGroup.UUID uuid;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
index 2facdb7..a31254f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-public class StarPredicate extends IndexPredicate<ChangeData> {
+public class StarPredicate extends ChangeIndexPredicate {
   private final Account.Id accountId;
   private final String label;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
index 35a5a29..d8d5258 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmissionIdPredicate extends IndexPredicate<ChangeData> {
+class SubmissionIdPredicate extends ChangeIndexPredicate {
 
   SubmissionIdPredicate(String changeSet) {
     super(ChangeField.SUBMISSIONID, changeSet);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
new file mode 100644
index 0000000..9242d9d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2016 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.query.change;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.query.Matchable;
+
+import java.sql.Timestamp;
+
+public abstract class TimestampRangeChangePredicate extends
+    TimestampRangePredicate<ChangeData> implements Matchable<ChangeData> {
+  protected TimestampRangeChangePredicate(FieldDef<ChangeData, Timestamp> def,
+      String name, String value) {
+    super(def, name, value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
index aed8831..e9be4cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
@@ -27,7 +26,7 @@
 import java.io.IOException;
 import java.util.List;
 
-class TrackingIdPredicate extends IndexPredicate<ChangeData> {
+class TrackingIdPredicate extends ChangeIndexPredicate {
   private static final Logger log = LoggerFactory.getLogger(TrackingIdPredicate.class);
 
   private final TrackingFooters trackingFooters;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
index 6f39a12..f38ddfa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.gerrit.reviewdb.server.DisabledChangesReviewDbWrapper;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index cc3dc5d..0b7e8b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.client.SystemConfig;
 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.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -92,7 +93,7 @@
   }
 
   public void update(final UpdateUI ui) throws OrmException {
-    try (ReviewDb db = unwrap(schema.open())) {
+    try (ReviewDb db = ReviewDbUtil.unwrapDb(schema.open())) {
 
       final SchemaVersion u = updater.get();
       final CurrentSchemaVersion version = getSchemaVersion(db);
@@ -115,13 +116,6 @@
     }
   }
 
-  private static ReviewDb unwrap(ReviewDb db) {
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
-  }
-
   private CurrentSchemaVersion getSchemaVersion(final ReviewDb db) {
     try {
       return db.schemaVersion().get(new CurrentSchemaVersion.Key());
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm
deleted file mode 100644
index 7b8e321..0000000
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergeFail.vm
+++ /dev/null
@@ -1,46 +0,0 @@
-## Copyright (C) 2010 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.
-##
-##
-## Template Type:
-## -------------
-## This is a velocity mail template, see: http://velocity.apache.org and the
-## gerrit-docs:config-mail.txt for more info on modifying gerrit mail templates.
-##
-## Template File Names and extensions:
-## ----------------------------------
-## Gerrit will use templates ending in ".vm" but will ignore templates ending
-## in ".vm.example".  If a .vm template does not exist, the default internal
-## gerrit template which is the same as the .vm.example will be used.  If you
-## want to override the default template, copy the .vm.example file to a .vm
-## file and edit it appropriately.
-##
-## This Template:
-## --------------
-## The MergeFail.vm template will determine the contents of the email related
-## to a failure upon attempting to merge a change to the head.  It is a
-## ChangeEmail: see ChangeSubject.vm and ChangeFooter.vm.
-##
-$fromName has submitted this change and it FAILED to merge.#**
-*##if($email.changeUrl) ( $email.changeUrl )#end
-
-
-Change subject: $change.subject
-......................................................................
-
-
-#if ($email.coverLetter)
-$email.coverLetter
-
-#end
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 35c1e74..ac7aed7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.server.query.AndPredicate;
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.change.AndSource;
+import com.google.gerrit.server.query.change.AndChangeSource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
@@ -74,7 +74,7 @@
   public void testNonIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(query(ChangeStatusPredicate.open()), in)
         .inOrder();
@@ -90,7 +90,7 @@
   public void testNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("foo:a OR foo:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(query(ChangeStatusPredicate.open()), in)
         .inOrder();
@@ -100,7 +100,7 @@
   public void testOneIndexPredicate() throws Exception {
     Predicate<ChangeData> in = parse("foo:a file:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(
             query(in.getChild(1)),
@@ -120,7 +120,7 @@
   public void testThreeLevelTreeWithSomeIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("-foo:a (file:b OR file:c)");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isSameAs(AndSource.class);
+    assertThat(out.getClass()).isSameAs(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
           query(in.getChild(1)),
@@ -146,7 +146,7 @@
   public void testIndexAndNonIndexPredicates() throws Exception {
     Predicate<ChangeData> in = parse("status:new bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(AndSource.class).isSameAs(out.getClass());
+    assertThat(AndChangeSource.class).isSameAs(out.getClass());
     assertThat(out.getChildren())
         .containsExactly(
           query(and(in.getChild(0), in.getChild(2))),
@@ -159,7 +159,7 @@
     Predicate<ChangeData> in =
         parse("(status:new OR status:draft) bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isEqualTo(AndSource.class);
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
           query(and(in.getChild(0), in.getChild(2))),
@@ -172,7 +172,7 @@
     Predicate<ChangeData> in =
         parse("(status:new OR file:a) bar:p file:b");
     Predicate<ChangeData> out = rewrite(in);
-    assertThat(out.getClass()).isEqualTo(AndSource.class);
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
           query(and(in.getChild(0), in.getChild(2))),
@@ -185,7 +185,7 @@
       throws Exception {
     Predicate<ChangeData> in = parse("limit:1 file:a limit:3");
     Predicate<ChangeData> out = rewrite(in, options(0, 5));
-    assertThat(out.getClass()).isEqualTo(AndSource.class);
+    assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
         .containsExactly(
           query(in.getChild(1), 5),
@@ -271,8 +271,8 @@
   }
 
   @SafeVarargs
-  private static AndSource andSource(Predicate<ChangeData>... preds) {
-    return new AndSource(Arrays.asList(preds));
+  private static AndChangeSource andSource(Predicate<ChangeData>... preds) {
+    return new AndChangeSource(Arrays.asList(preds));
   }
 
   private Predicate<ChangeData> rewrite(Predicate<ChangeData> in)
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 05bc552..43039f8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -49,7 +49,7 @@
 
     @Override
     public int getCardinality() {
-      throw new UnsupportedOperationException();
+      return 1;
     }
 
     @Override
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 5316bcb..0b5ed32 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -18,7 +18,6 @@
 import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gwtorm.server.OrmException;
 
 import org.junit.Ignore;
 
@@ -44,16 +43,6 @@
   }
 
   private Predicate<ChangeData> predicate(String name, String value) {
-    return new OperatorPredicate<ChangeData>(name, value) {
-      @Override
-      public boolean match(ChangeData object) throws OrmException {
-        return false;
-      }
-
-      @Override
-      public int getCost() {
-        return 0;
-      }
-    };
+    return new OperatorPredicate<ChangeData>(name, value) {};
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
new file mode 100644
index 0000000..6707c9f
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/ValidatorTest.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2016 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.mail;
+
+import static com.google.common.truth.Truth.assert_;
+
+import org.apache.commons.validator.routines.EmailValidator;
+import org.junit.Test;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+
+public class ValidatorTest {
+  private static final String UNSUPPORTED_PREFIX = "#! ";
+
+  @Test
+  public void validateTopLevelDomains() throws Exception {
+    try (InputStream in =
+        this.getClass().getResourceAsStream("tlds-alpha-by-domain.txt")) {
+      if (in == null) {
+        throw new Exception("TLD list not found");
+      }
+      BufferedReader r = new BufferedReader(new InputStreamReader(in));
+      String tld;
+      EmailValidator validator = EmailValidator.getInstance();
+      while ((tld = r.readLine()) != null) {
+        if (tld.startsWith("# ") || tld.startsWith("XN--")) {
+          // Ignore comments and non-latin domains
+          continue;
+        }
+        if (tld.startsWith(UNSUPPORTED_PREFIX)) {
+          String test = "test@example."
+              + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
+          assert_()
+            .withFailureMessage("expected invalid TLD \"" + test + "\"")
+            .that(validator.isValid(test))
+            .isFalse();
+        } else {
+          String test = "test@example." + tld.toLowerCase();
+          assert_()
+            .withFailureMessage("failed to validate TLD \"" + test + "\"")
+            .that(validator.isValid(test))
+            .isTrue();
+        }
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
index a0a6e40..7762e50 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
@@ -24,16 +24,6 @@
     protected TestPredicate(String name, String value) {
       super(name, value);
     }
-
-    @Override
-    public boolean match(String object) {
-      return false;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
   }
 
   protected static TestPredicate f(String name, String value) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
index 66eed8b..61bfe78 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeRebuilder;
-import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -71,7 +70,7 @@
   public void rebuildAndCheckAllChanges() throws Exception {
     rebuildAndCheckChanges(
         Iterables.transform(
-            unwrapDb().changes().all(),
+            getUnwrappedDb().changes().all(),
             ReviewDbUtil.changeIdFunction()));
   }
 
@@ -81,7 +80,7 @@
 
   public void rebuildAndCheckChanges(Iterable<Change.Id> changeIds)
       throws Exception {
-    ReviewDb db = unwrapDb();
+    ReviewDb db = getUnwrappedDb();
 
     List<ChangeBundle> allExpected = readExpected(changeIds);
 
@@ -124,7 +123,7 @@
 
   private List<ChangeBundle> readExpected(Iterable<Change.Id> changeIds)
       throws Exception {
-    ReviewDb db = unwrapDb();
+    ReviewDb db = getUnwrappedDb();
     boolean old = notesMigration.readChanges();
     try {
       notesMigration.setReadChanges(false);
@@ -142,7 +141,7 @@
 
   private void checkActual(List<ChangeBundle> allExpected, List<String> msgs)
       throws Exception {
-    ReviewDb db = unwrapDb();
+    ReviewDb db = getUnwrappedDb();
     boolean oldRead = notesMigration.readChanges();
     boolean oldWrite = notesMigration.writeChanges();
     try {
@@ -179,11 +178,8 @@
     }
   }
 
-  private ReviewDb unwrapDb() {
+  private ReviewDb getUnwrappedDb() {
     ReviewDb db = dbProvider.get();
-    if (db instanceof DisabledChangesReviewDbWrapper) {
-      db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
-    }
-    return db;
+    return  ReviewDbUtil.unwrapDb(db);
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
index f015636..ce2fe8f 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
@@ -29,6 +29,8 @@
     return readChanges;
   }
 
+  // Increase visbility from superclass, as tests may want to check whether
+  // NoteDb data is written in specific migration scenarios.
   @Override
   public boolean writeChanges() {
     return writeChanges;
diff --git a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/tlds-alpha-by-domain.txt b/gerrit-server/src/test/resources/com/google/gerrit/server/mail/tlds-alpha-by-domain.txt
new file mode 100644
index 0000000..9edf6a4
--- /dev/null
+++ b/gerrit-server/src/test/resources/com/google/gerrit/server/mail/tlds-alpha-by-domain.txt
@@ -0,0 +1,1329 @@
+# Version 2016060601, Last Updated Tue Jun  7 07:07:01 2016 UTC
+# From http://data.iana.org/TLD/tlds-alpha-by-domain.txt
+AAA
+AARP
+ABB
+ABBOTT
+ABBVIE
+ABOGADO
+ABUDHABI
+AC
+ACADEMY
+ACCENTURE
+ACCOUNTANT
+ACCOUNTANTS
+ACO
+ACTIVE
+ACTOR
+AD
+ADAC
+ADS
+ADULT
+AE
+AEG
+AERO
+#! AETNA
+AF
+AFL
+AG
+AGAKHAN
+AGENCY
+AI
+AIG
+AIRFORCE
+AIRTEL
+AKDN
+AL
+ALIBABA
+ALIPAY
+ALLFINANZ
+ALLY
+ALSACE
+AM
+AMICA
+AMSTERDAM
+ANALYTICS
+ANDROID
+ANQUAN
+AO
+APARTMENTS
+APP
+APPLE
+AQ
+AQUARELLE
+AR
+ARAMCO
+ARCHI
+ARMY
+ARPA
+ARTE
+AS
+ASIA
+ASSOCIATES
+AT
+ATTORNEY
+AU
+AUCTION
+AUDI
+AUDIO
+AUTHOR
+AUTO
+AUTOS
+AVIANCA
+AW
+AWS
+AX
+AXA
+AZ
+AZURE
+BA
+BABY
+BAIDU
+BAND
+BANK
+BAR
+BARCELONA
+BARCLAYCARD
+BARCLAYS
+BAREFOOT
+BARGAINS
+BAUHAUS
+BAYERN
+BB
+BBC
+BBVA
+BCG
+BCN
+BD
+BE
+BEATS
+BEER
+BENTLEY
+BERLIN
+BEST
+BET
+BF
+BG
+BH
+BHARTI
+BI
+BIBLE
+BID
+BIKE
+BING
+BINGO
+BIO
+BIZ
+BJ
+BLACK
+BLACKFRIDAY
+#! BLOG
+BLOOMBERG
+BLUE
+BM
+BMS
+BMW
+BN
+BNL
+BNPPARIBAS
+BO
+BOATS
+BOEHRINGER
+BOM
+BOND
+BOO
+BOOK
+BOOTS
+BOSCH
+BOSTIK
+BOT
+BOUTIQUE
+BR
+BRADESCO
+BRIDGESTONE
+BROADWAY
+BROKER
+BROTHER
+BRUSSELS
+BS
+BT
+BUDAPEST
+BUGATTI
+BUILD
+BUILDERS
+BUSINESS
+BUY
+BUZZ
+BV
+BW
+BY
+BZ
+BZH
+CA
+CAB
+CAFE
+CAL
+CALL
+CAMERA
+CAMP
+CANCERRESEARCH
+CANON
+CAPETOWN
+CAPITAL
+CAR
+CARAVAN
+CARDS
+CARE
+CAREER
+CAREERS
+CARS
+CARTIER
+CASA
+CASH
+CASINO
+CAT
+CATERING
+CBA
+CBN
+CC
+CD
+CEB
+CENTER
+CEO
+CERN
+CF
+CFA
+CFD
+CG
+CH
+CHANEL
+CHANNEL
+CHASE
+CHAT
+CHEAP
+CHLOE
+CHRISTMAS
+CHROME
+CHURCH
+CI
+CIPRIANI
+CIRCLE
+CISCO
+CITIC
+CITY
+CITYEATS
+CK
+CL
+CLAIMS
+CLEANING
+CLICK
+CLINIC
+CLINIQUE
+CLOTHING
+CLOUD
+CLUB
+CLUBMED
+CM
+CN
+CO
+COACH
+CODES
+COFFEE
+COLLEGE
+COLOGNE
+COM
+COMMBANK
+COMMUNITY
+COMPANY
+COMPARE
+COMPUTER
+COMSEC
+CONDOS
+CONSTRUCTION
+CONSULTING
+CONTACT
+CONTRACTORS
+COOKING
+COOL
+COOP
+CORSICA
+COUNTRY
+COUPON
+COUPONS
+COURSES
+CR
+CREDIT
+CREDITCARD
+CREDITUNION
+CRICKET
+CROWN
+CRS
+CRUISES
+CSC
+CU
+CUISINELLA
+CV
+CW
+CX
+CY
+CYMRU
+CYOU
+CZ
+DABUR
+DAD
+DANCE
+DATE
+DATING
+DATSUN
+DAY
+DCLK
+#! DDS
+DE
+DEALER
+DEALS
+DEGREE
+DELIVERY
+DELL
+DELOITTE
+DELTA
+DEMOCRAT
+DENTAL
+DENTIST
+DESI
+DESIGN
+DEV
+#! DHL
+DIAMONDS
+DIET
+DIGITAL
+DIRECT
+DIRECTORY
+DISCOUNT
+DJ
+DK
+DM
+DNP
+DO
+DOCS
+DOG
+DOHA
+DOMAINS
+#! DOT
+DOWNLOAD
+DRIVE
+#! DTV
+DUBAI
+DURBAN
+DVAG
+DZ
+EARTH
+EAT
+EC
+EDEKA
+EDU
+EDUCATION
+EE
+EG
+EMAIL
+EMERCK
+ENERGY
+ENGINEER
+ENGINEERING
+ENTERPRISES
+EPSON
+EQUIPMENT
+ER
+ERNI
+ES
+ESQ
+ESTATE
+ET
+EU
+EUROVISION
+EUS
+EVENTS
+EVERBANK
+EXCHANGE
+EXPERT
+EXPOSED
+EXPRESS
+EXTRASPACE
+FAGE
+FAIL
+FAIRWINDS
+FAITH
+FAMILY
+FAN
+FANS
+FARM
+FASHION
+FAST
+FEEDBACK
+FERRERO
+FI
+FILM
+FINAL
+FINANCE
+FINANCIAL
+FIRESTONE
+FIRMDALE
+FISH
+FISHING
+FIT
+FITNESS
+FJ
+FK
+FLICKR
+FLIGHTS
+#! FLIR
+FLORIST
+FLOWERS
+FLSMIDTH
+FLY
+FM
+FO
+FOO
+FOOTBALL
+FORD
+FOREX
+FORSALE
+FORUM
+FOUNDATION
+FOX
+FR
+FRESENIUS
+FRL
+FROGANS
+FRONTIER
+FTR
+FUND
+FURNITURE
+FUTBOL
+FYI
+GA
+GAL
+GALLERY
+GALLO
+GALLUP
+GAME
+#! GAMES
+GARDEN
+GB
+GBIZ
+GD
+GDN
+GE
+GEA
+GENT
+GENTING
+GF
+GG
+GGEE
+GH
+GI
+GIFT
+GIFTS
+GIVES
+GIVING
+GL
+GLASS
+GLE
+GLOBAL
+GLOBO
+GM
+GMAIL
+GMBH
+GMO
+GMX
+GN
+GOLD
+GOLDPOINT
+GOLF
+GOO
+GOOG
+GOOGLE
+GOP
+GOT
+GOV
+GP
+GQ
+GR
+GRAINGER
+GRAPHICS
+GRATIS
+GREEN
+GRIPE
+GROUP
+GS
+GT
+GU
+#! GUARDIAN
+GUCCI
+GUGE
+GUIDE
+GUITARS
+GURU
+GW
+GY
+HAMBURG
+HANGOUT
+HAUS
+HDFCBANK
+HEALTH
+HEALTHCARE
+HELP
+HELSINKI
+HERE
+HERMES
+HIPHOP
+#! HISAMITSU
+HITACHI
+HIV
+HK
+#! HKT
+HM
+HN
+HOCKEY
+HOLDINGS
+HOLIDAY
+HOMEDEPOT
+HOMES
+HONDA
+HORSE
+HOST
+HOSTING
+HOTELES
+HOTMAIL
+HOUSE
+HOW
+HR
+HSBC
+HT
+HTC
+HU
+HYUNDAI
+IBM
+ICBC
+ICE
+ICU
+ID
+IE
+IFM
+IINET
+IL
+IM
+IMAMAT
+IMMO
+IMMOBILIEN
+IN
+INDUSTRIES
+INFINITI
+INFO
+ING
+INK
+INSTITUTE
+INSURANCE
+INSURE
+INT
+INTERNATIONAL
+INVESTMENTS
+IO
+IPIRANGA
+IQ
+IR
+IRISH
+IS
+ISELECT
+ISMAILI
+IST
+ISTANBUL
+IT
+ITAU
+IWC
+JAGUAR
+JAVA
+JCB
+JCP
+JE
+JETZT
+JEWELRY
+JLC
+JLL
+JM
+JMP
+JNJ
+JO
+JOBS
+JOBURG
+JOT
+JOY
+JP
+JPMORGAN
+JPRS
+JUEGOS
+KAUFEN
+KDDI
+KE
+KERRYHOTELS
+KERRYLOGISTICS
+KERRYPROPERTIES
+KFH
+KG
+KH
+KI
+KIA
+KIM
+KINDER
+KITCHEN
+KIWI
+KM
+KN
+KOELN
+KOMATSU
+KP
+KPMG
+KPN
+KR
+KRD
+KRED
+KUOKGROUP
+KW
+KY
+KYOTO
+KZ
+LA
+LACAIXA
+LAMBORGHINI
+LAMER
+LANCASTER
+LAND
+LANDROVER
+LANXESS
+LASALLE
+LAT
+LATROBE
+LAW
+LAWYER
+LB
+LC
+LDS
+LEASE
+LECLERC
+LEGAL
+LEXUS
+LGBT
+LI
+LIAISON
+LIDL
+LIFE
+LIFEINSURANCE
+LIFESTYLE
+LIGHTING
+LIKE
+LIMITED
+LIMO
+LINCOLN
+LINDE
+LINK
+#! LIPSY
+LIVE
+LIVING
+LIXIL
+LK
+LOAN
+LOANS
+#! LOCKER
+LOCUS
+LOL
+LONDON
+LOTTE
+LOTTO
+LOVE
+LR
+LS
+LT
+LTD
+LTDA
+LU
+LUPIN
+LUXE
+LUXURY
+LV
+LY
+MA
+MADRID
+MAIF
+MAISON
+MAKEUP
+MAN
+MANAGEMENT
+MANGO
+MARKET
+MARKETING
+MARKETS
+MARRIOTT
+#! MATTEL
+MBA
+MC
+MD
+ME
+MED
+MEDIA
+MEET
+MELBOURNE
+MEME
+MEMORIAL
+MEN
+MENU
+MEO
+#! METLIFE
+MG
+MH
+MIAMI
+MICROSOFT
+MIL
+MINI
+MK
+ML
+#! MLB
+MLS
+MM
+MMA
+MN
+MO
+MOBI
+MOBILY
+MODA
+MOE
+MOI
+MOM
+MONASH
+MONEY
+MONTBLANC
+MORMON
+MORTGAGE
+MOSCOW
+MOTORCYCLES
+MOV
+MOVIE
+MOVISTAR
+MP
+MQ
+MR
+MS
+MT
+MTN
+MTPC
+MTR
+MU
+MUSEUM
+MUTUAL
+MUTUELLE
+MV
+MW
+MX
+MY
+MZ
+NA
+NADEX
+NAGOYA
+NAME
+NATURA
+NAVY
+NC
+NE
+NEC
+NET
+NETBANK
+#! NETFLIX
+NETWORK
+NEUSTAR
+NEW
+NEWS
+#! NEXT
+#! NEXTDIRECT
+NEXUS
+NF
+NG
+NGO
+NHK
+NI
+NICO
+NIKON
+NINJA
+NISSAN
+NISSAY
+NL
+NO
+NOKIA
+NORTHWESTERNMUTUAL
+NORTON
+NOWRUZ
+#! NOWTV
+NP
+NR
+NRA
+NRW
+NTT
+NU
+NYC
+NZ
+OBI
+OFFICE
+OKINAWA
+#! OLAYAN
+#! OLAYANGROUP
+#! OLLO
+OM
+OMEGA
+ONE
+ONG
+ONL
+ONLINE
+OOO
+ORACLE
+ORANGE
+ORG
+ORGANIC
+ORIGINS
+OSAKA
+OTSUKA
+#! OTT
+OVH
+PA
+PAGE
+PAMPEREDCHEF
+PANERAI
+PARIS
+PARS
+PARTNERS
+PARTS
+PARTY
+PASSAGENS
+#! PCCW
+PE
+PET
+PF
+PG
+PH
+PHARMACY
+PHILIPS
+PHOTO
+PHOTOGRAPHY
+PHOTOS
+PHYSIO
+PIAGET
+PICS
+PICTET
+PICTURES
+PID
+PIN
+PING
+PINK
+#! PIONEER
+PIZZA
+PK
+PL
+PLACE
+PLAY
+PLAYSTATION
+PLUMBING
+PLUS
+PM
+PN
+POHL
+POKER
+PORN
+POST
+PR
+PRAXI
+PRESS
+PRO
+PROD
+PRODUCTIONS
+PROF
+PROGRESSIVE
+PROMO
+PROPERTIES
+PROPERTY
+PROTECTION
+PS
+PT
+PUB
+PW
+PWC
+PY
+QA
+QPON
+QUEBEC
+QUEST
+RACING
+RE
+READ
+#! REALESTATE
+REALTOR
+REALTY
+RECIPES
+RED
+REDSTONE
+REDUMBRELLA
+REHAB
+REISE
+REISEN
+REIT
+REN
+RENT
+RENTALS
+REPAIR
+REPORT
+REPUBLICAN
+REST
+RESTAURANT
+REVIEW
+REVIEWS
+REXROTH
+RICH
+#! RICHARDLI
+RICOH
+RIO
+RIP
+RO
+ROCHER
+ROCKS
+RODEO
+ROOM
+RS
+RSVP
+RU
+RUHR
+RUN
+RW
+RWE
+RYUKYU
+SA
+SAARLAND
+SAFE
+SAFETY
+SAKURA
+SALE
+SALON
+SAMSUNG
+SANDVIK
+SANDVIKCOROMANT
+SANOFI
+SAP
+SAPO
+SARL
+SAS
+SAXO
+SB
+SBI
+SBS
+SC
+SCA
+SCB
+SCHAEFFLER
+SCHMIDT
+SCHOLARSHIPS
+SCHOOL
+SCHULE
+SCHWARZ
+SCIENCE
+SCOR
+SCOT
+SD
+SE
+SEAT
+SECURITY
+SEEK
+SELECT
+SENER
+SERVICES
+SEVEN
+SEW
+SEX
+SEXY
+SFR
+SG
+SH
+SHARP
+SHAW
+SHELL
+SHIA
+SHIKSHA
+SHOES
+#! SHOP
+SHOUJI
+SHOW
+SHRIRAM
+SI
+SINA
+SINGLES
+SITE
+SJ
+SK
+SKI
+SKIN
+SKY
+SKYPE
+SL
+SM
+SMILE
+SN
+SNCF
+SO
+SOCCER
+SOCIAL
+SOFTBANK
+SOFTWARE
+SOHU
+SOLAR
+SOLUTIONS
+SONG
+SONY
+SOY
+SPACE
+SPIEGEL
+SPOT
+SPREADBETTING
+SR
+SRL
+ST
+STADA
+STAR
+STARHUB
+STATEBANK
+STATEFARM
+STATOIL
+STC
+STCGROUP
+STOCKHOLM
+STORAGE
+STORE
+STREAM
+STUDIO
+STUDY
+STYLE
+SU
+SUCKS
+SUPPLIES
+SUPPLY
+SUPPORT
+SURF
+SURGERY
+SUZUKI
+SV
+SWATCH
+SWISS
+SX
+SY
+SYDNEY
+SYMANTEC
+SYSTEMS
+SZ
+TAB
+TAIPEI
+TALK
+TAOBAO
+TATAMOTORS
+TATAR
+TATTOO
+TAX
+TAXI
+TC
+TCI
+TD
+TEAM
+TECH
+TECHNOLOGY
+TEL
+TELECITY
+TELEFONICA
+TEMASEK
+TENNIS
+TEVA
+TF
+TG
+TH
+THD
+THEATER
+THEATRE
+TICKETS
+TIENDA
+TIFFANY
+TIPS
+TIRES
+TIROL
+TJ
+TK
+TL
+TM
+TMALL
+TN
+TO
+TODAY
+TOKYO
+TOOLS
+TOP
+TORAY
+TOSHIBA
+TOTAL
+TOURS
+TOWN
+TOYOTA
+TOYS
+TR
+TRADE
+TRADING
+TRAINING
+TRAVEL
+TRAVELERS
+TRAVELERSINSURANCE
+TRUST
+TRV
+TT
+TUBE
+TUI
+TUNES
+TUSHU
+TV
+TVS
+TW
+TZ
+UA
+UBS
+UG
+UK
+UNICOM
+UNIVERSITY
+UNO
+UOL
+#! UPS
+US
+UY
+UZ
+VA
+VACATIONS
+VANA
+VC
+VE
+VEGAS
+VENTURES
+VERISIGN
+VERSICHERUNG
+VET
+VG
+VI
+VIAJES
+VIDEO
+VIG
+VIKING
+VILLAS
+VIN
+VIP
+VIRGIN
+VISION
+VISTA
+VISTAPRINT
+VIVA
+VLAANDEREN
+VN
+VODKA
+VOLKSWAGEN
+VOTE
+VOTING
+VOTO
+VOYAGE
+VU
+VUELOS
+WALES
+WALTER
+WANG
+WANGGOU
+#! WARMAN
+WATCH
+WATCHES
+WEATHER
+WEATHERCHANNEL
+WEBCAM
+WEBER
+WEBSITE
+WED
+WEDDING
+WEIBO
+WEIR
+WF
+WHOSWHO
+WIEN
+WIKI
+WILLIAMHILL
+WIN
+WINDOWS
+WINE
+WME
+WOLTERSKLUWER
+WORK
+WORKS
+WORLD
+WS
+WTC
+WTF
+XBOX
+XEROX
+XIHUAN
+XIN
+XN--11B4C3D
+XN--1CK2E1B
+XN--1QQW23A
+XN--30RR7Y
+XN--3BST00M
+XN--3DS443G
+XN--3E0B707E
+XN--3PXU8K
+XN--42C2D9A
+XN--45BRJ9C
+XN--45Q11C
+XN--4GBRIM
+XN--55QW42G
+XN--55QX5D
+XN--5TZM5G
+XN--6FRZ82G
+XN--6QQ986B3XL
+XN--80ADXHKS
+XN--80AO21A
+XN--80ASEHDB
+XN--80ASWG
+XN--8Y0A063A
+XN--90A3AC
+XN--90AIS
+XN--9DBQ2A
+XN--9ET52U
+XN--9KRT00A
+XN--B4W605FERD
+XN--BCK1B9A5DRE4C
+XN--C1AVG
+XN--C2BR7G
+XN--CCK2B3B
+XN--CG4BKI
+XN--CLCHC0EA0B2G2A9GCD
+XN--CZR694B
+XN--CZRS0T
+XN--CZRU2D
+XN--D1ACJ3B
+XN--D1ALF
+XN--E1A4C
+XN--ECKVDTC9D
+XN--EFVY88H
+XN--ESTV75G
+XN--FCT429K
+XN--FHBEI
+XN--FIQ228C5HS
+XN--FIQ64B
+XN--FIQS8S
+XN--FIQZ9S
+XN--FJQ720A
+XN--FLW351E
+XN--FPCRJ9C3D
+XN--FZC2C9E2C
+XN--FZYS8D69UVGM
+XN--G2XX48C
+XN--GCKR3F0F
+XN--GECRJ9C
+XN--H2BRJ9C
+XN--HXT814E
+XN--I1B6B1A6A2E
+XN--IMR513N
+XN--IO0A7I
+XN--J1AEF
+XN--J1AMH
+XN--J6W193G
+XN--JLQ61U9W7B
+XN--JVR189M
+XN--KCRX77D1X4A
+XN--KPRW13D
+XN--KPRY57D
+XN--KPU716F
+XN--KPUT3I
+XN--L1ACC
+XN--LGBBAT1AD8J
+XN--MGB9AWBF
+XN--MGBA3A3EJT
+XN--MGBA3A4F16A
+XN--MGBA7C0BBN0A
+XN--MGBAAM7A8H
+XN--MGBAB2BD
+XN--MGBAYH7GPA
+XN--MGBB9FBPOB
+XN--MGBBH1A71E
+XN--MGBC0A9AZCG
+XN--MGBCA7DZDO
+XN--MGBERP4A5D4AR
+XN--MGBPL2FH
+XN--MGBT3DHD
+XN--MGBTX2B
+XN--MGBX4CD0AB
+XN--MIX891F
+XN--MK1BU44C
+XN--MXTQ1M
+XN--NGBC5AZD
+XN--NGBE9E0A
+XN--NODE
+XN--NQV7F
+XN--NQV7FS00EMA
+XN--NYQY26A
+XN--O3CW4H
+XN--OGBPF8FL
+XN--P1ACF
+XN--P1AI
+XN--PBT977C
+XN--PGBS0DH
+XN--PSSY2U
+XN--Q9JYB4C
+XN--QCKA1PMC
+XN--QXAM
+XN--RHQV96G
+XN--ROVU88B
+XN--S9BRJ9C
+XN--SES554G
+XN--T60B56A
+XN--TCKWE
+XN--UNUP4Y
+XN--VERMGENSBERATER-CTB
+XN--VERMGENSBERATUNG-PWB
+XN--VHQUV
+XN--VUQ861B
+XN--W4R85EL8FHU5DNRA
+XN--W4RS40L
+XN--WGBH1C
+XN--WGBL6A
+XN--XHQ521B
+XN--XKC2AL3HYE2A
+XN--XKC2DL3A5EE0H
+XN--Y9A3AQ
+XN--YFRO4I67O
+XN--YGBI2AMMX
+XN--ZFR164B
+XPERIA
+XXX
+XYZ
+YACHTS
+YAHOO
+YAMAXUN
+YANDEX
+YE
+YODOBASHI
+YOGA
+YOKOHAMA
+YOU
+YOUTUBE
+YT
+YUN
+ZA
+#! ZAPPOS
+ZARA
+ZERO
+ZIP
+ZM
+ZONE
+ZUERICH
+ZW
diff --git a/lib/commons/BUCK b/lib/commons/BUCK
index f9bf064..7c27477 100644
--- a/lib/commons/BUCK
+++ b/lib/commons/BUCK
@@ -72,10 +72,15 @@
   exclude = ['META-INF/LICENSE'],
 )
 
+# When updating the version of commons-validator, also update the
+# list of supported TLDs in:
+#    gerrit-server/src/test/resources/com/google/gerrit/server/mail/tlds-alpha-by-domain.txt
+# from:
+#    http://data.iana.org/TLD/tlds-alpha-by-domain.txt
 maven_jar(
   name = 'validator',
-  id = 'commons-validator:commons-validator:1.4.1',
-  sha1 = '2231238e391057a53f92bde5bbc588622c1956c3',
+  id = 'commons-validator:commons-validator:1.5.1',
+  sha1 = '86d05a46e8f064b300657f751b5a98c62807e2a0',
   license = 'Apache2.0',
 )
 
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 6eecd42..2d40ee2 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 6eecd42fd629c700409826273d9ed02499a1d12c
+Subproject commit 2d40ee25a029deaefeddb6ffe5679a68aa10d8db
diff --git a/plugins/replication b/plugins/replication
index a0cf9a2..b9c11b4 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit a0cf9a2919ba11feef712cf6e6390669a46d24c5
+Subproject commit b9c11b4d4ed37f566e6e2daa11d96e1ca3d23c02
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 1896c76..feb21e2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -28,9 +28,7 @@
       GrDiffBuilderSideBySide.prototype);
   GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
 
-  GrDiffBuilderImage.prototype.emitDiff = function() {
-    this.emitGroup(this._groups[0]);
-
+  GrDiffBuilderImage.prototype.renderDiffImages = function() {
     var section = this._createElement('tbody', 'image-diff');
 
     this._emitImagePair(section);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index 315692a..557e7b8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -14,12 +14,14 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
 
 <dom-module id="gr-diff-builder">
   <template>
     <div class="contentWrapper">
       <content></content>
     </div>
+    <gr-diff-processor id="processor"></gr-diff-processor>
   </template>
   <script src="../gr-diff/gr-diff-line.js"></script>
   <script src="../gr-diff/gr-diff-group.js"></script>
@@ -59,7 +61,11 @@
 
         render: function(diff, comments, prefs) {
           this._builder = this._getDiffBuilder(diff, comments, prefs);
-          this._renderDiff();
+
+          this.$.processor.context = prefs.context;
+          this.$.processor.keyLocations = this._getCommentLocations(comments);
+          this.$.processor.process(diff.content)
+              .then(this._renderDiff.bind(this));
         },
 
         getLineElByChild: function(node) {
@@ -77,6 +83,12 @@
           return null;
         },
 
+        getLineNumberByChild: function(node) {
+          var lineEl = this.getLineElByChild(node);
+          return lineEl ?
+              parseInt(lineEl.getAttribute('data-value'), 10) : null;
+        },
+
         renderLineRange: function(startLine, endLine, opt_side) {
           var groups =
               this._builder.getGroupsByLineRange(startLine, endLine, opt_side);
@@ -176,7 +188,7 @@
         },
 
         showContext: function(newGroups, sectionEl) {
-          var groups = this._builder._groups;
+          var groups = this._builder.groups;
           // TODO(viktard): Polyfill findIndex for IE10.
           var contextIndex = groups.findIndex(function(group) {
             return group.element == sectionEl;
@@ -207,9 +219,14 @@
           throw Error('Unsupported diff view mode: ' + this.viewMode);
         },
 
-        _renderDiff: function() {
+        _renderDiff: function(groups) {
+          this._builder.groups = groups;
+
           this._clearDiffContent();
           this.emitDiff();
+          if (this.isImageDiff) {
+            this._builder.renderDiffImages();
+          }
           this.async(function() {
             this.fire('render');
           }, 1);
@@ -218,6 +235,23 @@
         _clearDiffContent: function() {
           this.diffElement.innerHTML = null;
         },
+
+        _getCommentLocations: function(comments) {
+          var result = {
+            left: {},
+            right: {},
+          };
+          for (var side in comments) {
+            if (side !== GrDiffBuilder.Side.LEFT &&
+                side !== GrDiffBuilder.Side.RIGHT) {
+              continue;
+            }
+            comments[side].forEach(function(c) {
+              result[side][c.line || GrDiffLine.FILE] = true;
+            });
+          }
+          return result;
+        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index b96423e..b1256db 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -22,10 +22,7 @@
     this._comments = comments;
     this._prefs = prefs;
     this._outputEl = outputEl;
-    this._groups = [];
-
-    this._commentLocations = this._getCommentLocations(comments);
-    this._processContent(diff.content, this._groups, prefs.context);
+    this.groups = [];
   }
 
   GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
@@ -63,8 +60,8 @@
   var PARTIAL_CONTEXT_AMOUNT = 10;
 
   GrDiffBuilder.prototype.emitDiff = function() {
-    for (var i = 0; i < this._groups.length; i++) {
-      this.emitGroup(this._groups[i]);
+    for (var i = 0; i < this.groups.length; i++) {
+      this.emitGroup(this.groups[i]);
     }
   };
 
@@ -79,8 +76,8 @@
   };
 
   GrDiffBuilder.prototype.renderSection = function(element) {
-    for (var i = 0; i < this._groups.length; i++) {
-      var group = this._groups[i];
+    for (var i = 0; i < this.groups.length; i++) {
+      var group = this.groups[i];
       if (group.element === element) {
         var newElement = this.buildSectionElement(group);
         group.element.parentElement.replaceChild(newElement, group.element);
@@ -93,8 +90,8 @@
   GrDiffBuilder.prototype.getGroupsByLineRange = function(
       startLine, endLine, opt_side) {
     var groups = [];
-    for (var i = 0; i < this._groups.length; i++) {
-      var group = this._groups[i];
+    for (var i = 0; i < this.groups.length; i++) {
+      var group = this.groups[i];
       if (group.lines.length === 0) {
         continue;
       }
@@ -139,196 +136,11 @@
         function(group) { return group.element; });
   };
 
-  GrDiffBuilder.prototype._processContent = function(content, groups, context) {
-    this._appendFileComments(groups);
-
-    var WHOLE_FILE = -1;
-    context = content.length > 1 ? context : WHOLE_FILE;
-
-    var lineNums = {
-      left: 0,
-      right: 0,
-    };
-    content = this._splitCommonGroupsWithComments(content, lineNums);
-    for (var i = 0; i < content.length; i++) {
-      var group = content[i];
-      var lines = [];
-
-      if (group[GrDiffBuilder.GroupType.BOTH] !== undefined) {
-        var rows = group[GrDiffBuilder.GroupType.BOTH];
-        this._appendCommonLines(rows, lines, lineNums);
-
-        var hiddenRange = [context, rows.length - context];
-        if (i === 0) {
-          hiddenRange[0] = 0;
-        } else if (i === content.length - 1) {
-          hiddenRange[1] = rows.length;
-        }
-
-        if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
-          this._insertContextGroups(groups, lines, hiddenRange);
-        } else {
-          groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
-        }
-        continue;
-      }
-
-      if (group[GrDiffBuilder.GroupType.REMOVED] !== undefined) {
-        var highlights = undefined;
-        if (group[GrDiffBuilder.Highlights.REMOVED] !== undefined) {
-          highlights = this._normalizeIntralineHighlights(
-              group[GrDiffBuilder.GroupType.REMOVED],
-              group[GrDiffBuilder.Highlights.REMOVED]);
-        }
-        this._appendRemovedLines(group[GrDiffBuilder.GroupType.REMOVED], lines,
-            lineNums, highlights);
-      }
-
-      if (group[GrDiffBuilder.GroupType.ADDED] !== undefined) {
-        var highlights = undefined;
-        if (group[GrDiffBuilder.Highlights.ADDED] !== undefined) {
-          highlights = this._normalizeIntralineHighlights(
-            group[GrDiffBuilder.GroupType.ADDED],
-            group[GrDiffBuilder.Highlights.ADDED]);
-        }
-        this._appendAddedLines(group[GrDiffBuilder.GroupType.ADDED], lines,
-            lineNums, highlights);
-      }
-      groups.push(new GrDiffGroup(GrDiffGroup.Type.DELTA, lines));
-    }
-  };
-
-  GrDiffBuilder.prototype._appendFileComments = function(groups) {
-    var line = new GrDiffLine(GrDiffLine.Type.BOTH);
-    line.beforeNumber = GrDiffLine.FILE;
-    line.afterNumber = GrDiffLine.FILE;
-    groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]));
-  };
-
-  GrDiffBuilder.prototype._getCommentLocations = function(comments) {
-    var result = {
-      left: {},
-      right: {},
-    };
-    for (var side in comments) {
-      if (side !== GrDiffBuilder.Side.LEFT &&
-          side !== GrDiffBuilder.Side.RIGHT) {
-        continue;
-      }
-      comments[side].forEach(function(c) {
-        result[side][c.line || GrDiffLine.FILE] = true;
-      });
-    }
-    return result;
-  };
-
   GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) {
     return this._commentLocations[side][lineNum] === true;
   };
 
-  // In order to show comments out of the bounds of the selected context,
-  // treat them as separate chunks within the model so that the content (and
-  // context surrounding it) renders correctly.
-  GrDiffBuilder.prototype._splitCommonGroupsWithComments = function(content,
-      lineNums) {
-    var result = [];
-    var leftLineNum = lineNums.left;
-    var rightLineNum = lineNums.right;
-    for (var i = 0; i < content.length; i++) {
-      if (!content[i].ab) {
-        result.push(content[i]);
-        if (content[i].a) {
-          leftLineNum += content[i].a.length;
-        }
-        if (content[i].b) {
-          rightLineNum += content[i].b.length;
-        }
-        continue;
-      }
-      var chunk = content[i].ab;
-      var currentChunk = {ab: []};
-      for (var j = 0; j < chunk.length; j++) {
-        leftLineNum++;
-        rightLineNum++;
-        if (this._commentIsAtLineNum(GrDiffBuilder.Side.LEFT, leftLineNum) ||
-            this._commentIsAtLineNum(GrDiffBuilder.Side.RIGHT, rightLineNum)) {
-          if (currentChunk.ab && currentChunk.ab.length > 0) {
-            result.push(currentChunk);
-            currentChunk = {ab: []};
-          }
-          result.push({ab: [chunk[j]]});
-        } else {
-          currentChunk.ab.push(chunk[j]);
-        }
-      }
-      // != instead of !== because we want to cover both undefined and null.
-      if (currentChunk.ab != null && currentChunk.ab.length > 0) {
-        result.push(currentChunk);
-      }
-    }
-    return result;
-  };
-
-  // The `highlights` array consists of a list of <skip length, mark length>
-  // pairs, where the skip length is the number of characters between the
-  // end of the previous edit and the start of this edit, and the mark
-  // length is the number of edited characters following the skip. The start
-  // of the edits is from the beginning of the related diff content lines.
-  //
-  // Note that the implied newline character at the end of each line is
-  // included in the length calculation, and thus it is possible for the
-  // edits to span newlines.
-  //
-  // A line highlight object consists of three fields:
-  // - contentIndex: The index of the diffChunk `content` field (the line
-  //   being referred to).
-  // - startIndex: Where the highlight should begin.
-  // - endIndex: (optional) Where the highlight should end. If omitted, the
-  //   highlight is meant to be a continuation onto the next line.
-  GrDiffBuilder.prototype._normalizeIntralineHighlights = function(content,
-      highlights) {
-    var contentIndex = 0;
-    var idx = 0;
-    var normalized = [];
-    for (var i = 0; i < highlights.length; i++) {
-      var line = content[contentIndex] + '\n';
-      var hl = highlights[i];
-      var j = 0;
-      while (j < hl[0]) {
-        if (idx === line.length) {
-          idx = 0;
-          line = content[++contentIndex] + '\n';
-          continue;
-        }
-        idx++;
-        j++;
-      }
-      var lineHighlight = {
-        contentIndex: contentIndex,
-        startIndex: idx,
-      };
-
-      j = 0;
-      while (line && j < hl[1]) {
-        if (idx === line.length) {
-          idx = 0;
-          line = content[++contentIndex] + '\n';
-          normalized.push(lineHighlight);
-          lineHighlight = {
-            contentIndex: contentIndex,
-            startIndex: idx,
-          };
-          continue;
-        }
-        idx++;
-        j++;
-      }
-      lineHighlight.endIndex = idx;
-      normalized.push(lineHighlight);
-    }
-    return normalized;
-  };
-
+  // TODO(wyatta): Move this completely into the processor.
   GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
       hiddenRange) {
     var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
@@ -350,46 +162,6 @@
     }
   };
 
-  GrDiffBuilder.prototype._appendCommonLines = function(rows, lines, lineNums) {
-    for (var i = 0; i < rows.length; i++) {
-      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.text = rows[i];
-      line.beforeNumber = ++lineNums.left;
-      line.afterNumber = ++lineNums.right;
-      lines.push(line);
-    }
-  };
-
-  GrDiffBuilder.prototype._appendRemovedLines = function(rows, lines, lineNums,
-      opt_highlights) {
-    for (var i = 0; i < rows.length; i++) {
-      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      line.text = rows[i];
-      line.beforeNumber = ++lineNums.left;
-      if (opt_highlights) {
-        line.highlights = opt_highlights.filter(function(hl) {
-          return hl.contentIndex === i;
-        });
-      }
-      lines.push(line);
-    }
-  };
-
-  GrDiffBuilder.prototype._appendAddedLines = function(rows, lines, lineNums,
-      opt_highlights) {
-    for (var i = 0; i < rows.length; i++) {
-      var line = new GrDiffLine(GrDiffLine.Type.ADD);
-      line.text = rows[i];
-      line.afterNumber = ++lineNums.right;
-      if (opt_highlights) {
-        line.highlights = opt_highlights.filter(function(hl) {
-          return hl.contentIndex === i;
-        });
-      }
-      lines.push(line);
-    }
-  };
-
   GrDiffBuilder.prototype._createContextControl = function(section, line) {
     if (!line.contextGroup || !line.contextGroup.lines.length) {
       return null;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 879d8592..cda4dc8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -18,11 +18,23 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-diff-builder</title>
 
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
 <script src="../gr-diff/gr-diff-group.js"></script>
 <script src="gr-diff-builder.js"></script>
 
+<link rel="import" href="gr-diff-builder.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-builder>
+      <table id="diffTable"></table>
+    </gr-diff-builder>
+  </template>
+</test-fixture>
+
+
 <script>
   suite('gr-diff-builder tests', function() {
     var builder;
@@ -36,205 +48,6 @@
       builder = new GrDiffBuilder({content: []}, {left: [], right: []}, prefs);
     });
 
-    test('process loaded content', function() {
-      var content = [
-        {
-          ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
-          ]
-        },
-        {
-          a: [
-            '  Welcome ',
-            '  to the wooorld of tomorrow!',
-          ],
-          b: [
-            '  Hello, world!',
-          ],
-        },
-        {
-          ab: [
-            'Leela: This is the only place the ship can’t hear us, so ',
-            'everyone pretend to shower.',
-            'Fry: Same as every day. Got it.',
-          ]
-        },
-      ];
-      var groups = [];
-
-      builder._processContent(content, groups, -1);
-
-      assert.equal(groups.length, 4);
-
-      var group = groups[0];
-      assert.equal(group.type, GrDiffGroup.Type.BOTH);
-      assert.equal(group.lines.length, 1);
-      assert.equal(group.lines[0].text, '');
-      assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
-      assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
-
-      group = groups[1];
-      assert.equal(group.type, GrDiffGroup.Type.BOTH);
-      assert.equal(group.lines.length, 2);
-      assert.equal(group.lines.length, 2);
-
-      function beforeNumberFn(l) { return l.beforeNumber; }
-      function afterNumberFn(l) { return l.afterNumber; }
-      function textFn(l) { return l.text; }
-
-      assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
-      assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
-      assert.deepEqual(group.lines.map(textFn), [
-        '<!DOCTYPE html>',
-        '<meta charset="utf-8">',
-      ]);
-
-      group = groups[2];
-      assert.equal(group.type, GrDiffGroup.Type.DELTA);
-      assert.equal(group.lines.length, 3);
-      assert.equal(group.adds.length, 1);
-      assert.equal(group.removes.length, 2);
-      assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
-      assert.deepEqual(group.adds.map(afterNumberFn), [3]);
-      assert.deepEqual(group.removes.map(textFn), [
-        '  Welcome ',
-        '  to the wooorld of tomorrow!',
-      ]);
-      assert.deepEqual(group.adds.map(textFn), [
-        '  Hello, world!',
-      ]);
-
-      group = groups[3];
-      assert.equal(group.type, GrDiffGroup.Type.BOTH);
-      assert.equal(group.lines.length, 3);
-      assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
-      assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
-      assert.deepEqual(group.lines.map(textFn), [
-        'Leela: This is the only place the ship can’t hear us, so ',
-        'everyone pretend to shower.',
-        'Fry: Same as every day. Got it.',
-      ]);
-    });
-
-    test('insert context groups', function() {
-      var content = [
-        {ab: []},
-        {a: ['all work and no play make andybons a dull boy']},
-        {ab: []},
-        {b: ['elgoog elgoog elgoog']},
-        {ab: []},
-      ];
-      for (var i = 0; i < 100; i++) {
-        content[0].ab.push('all work and no play make jack a dull boy');
-        content[4].ab.push('all work and no play make jill a dull girl');
-      }
-      for (var i = 0; i < 5; i++) {
-        content[2].ab.push('no tv and no beer make homer go crazy');
-      }
-      var groups = [];
-      var context = 10;
-
-      builder._processContent(content, groups, context);
-
-      assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
-      assert.equal(groups[0].lines.length, 1);
-      assert.equal(groups[0].lines[0].text, '');
-      assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
-      assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
-
-      assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-      assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
-      assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
-      groups[1].lines[0].contextGroup.lines.forEach(function(l) {
-        assert.equal(l.text, content[0].ab[0]);
-      });
-
-      assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-      assert.equal(groups[2].lines.length, context);
-      groups[2].lines.forEach(function(l) {
-        assert.equal(l.text, content[0].ab[0]);
-      });
-
-      assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
-      assert.equal(groups[3].lines.length, 1);
-      assert.equal(groups[3].removes.length, 1);
-      assert.equal(groups[3].removes[0].text,
-          'all work and no play make andybons a dull boy');
-
-      assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-      assert.equal(groups[4].lines.length, 5);
-      groups[4].lines.forEach(function(l) {
-        assert.equal(l.text, content[2].ab[0]);
-      });
-
-      assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-      assert.equal(groups[5].lines.length, 1);
-      assert.equal(groups[5].adds.length, 1);
-      assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
-
-      assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
-      assert.equal(groups[6].lines.length, context);
-      groups[6].lines.forEach(function(l) {
-        assert.equal(l.text, content[4].ab[0]);
-      });
-
-      assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-      assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
-      assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
-      groups[7].lines[0].contextGroup.lines.forEach(function(l) {
-        assert.equal(l.text, content[4].ab[0]);
-      });
-
-      content = [
-        {a: ['all work and no play make andybons a dull boy']},
-        {ab: []},
-        {b: ['elgoog elgoog elgoog']},
-      ];
-      for (var i = 0; i < 50; i++) {
-        content[1].ab.push('no tv and no beer make homer go crazy');
-      }
-      groups = [];
-
-      builder._processContent(content, groups, 10);
-
-      assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
-      assert.equal(groups[0].lines.length, 1);
-      assert.equal(groups[0].lines[0].text, '');
-      assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
-      assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
-
-      assert.equal(groups[1].type, GrDiffGroup.Type.DELTA);
-      assert.equal(groups[1].lines.length, 1);
-      assert.equal(groups[1].removes.length, 1);
-      assert.equal(groups[1].removes[0].text,
-          'all work and no play make andybons a dull boy');
-
-      assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
-      assert.equal(groups[2].lines.length, context);
-      groups[2].lines.forEach(function(l) {
-        assert.equal(l.text, content[1].ab[0]);
-      });
-
-      assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
-      assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
-      assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
-      groups[3].lines[0].contextGroup.lines.forEach(function(l) {
-        assert.equal(l.text, content[1].ab[0]);
-      });
-
-      assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
-      assert.equal(groups[4].lines.length, context);
-      groups[4].lines.forEach(function(l) {
-        assert.equal(l.text, content[1].ab[0]);
-      });
-
-      assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
-      assert.equal(groups[5].lines.length, 1);
-      assert.equal(groups[5].adds.length, 1);
-      assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
-    });
-
     test('context control buttons', function() {
       var section = {};
       var line = {contextGroup: {lines: []}};
@@ -412,153 +225,11 @@
           [{id: 'l3', line: 3}, {id: 'r5', line: 5}]);
     });
 
-    test('break up common diff chunks', function() {
-      builder._commentLocations = {
-        left: {1: true},
-        right: {10: true},
-      };
-      var lineNums = {
-        left: 0,
-        right: 0,
-      };
-      var content = [
-        {
-          ab: [
-            'Copyright (C) 2015 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.',
-          ]
-        }
-      ];
-      var result = builder._splitCommonGroupsWithComments(content, lineNums);
-      assert.deepEqual(result, [
-        {
-          ab: ['Copyright (C) 2015 The Android Open Source Project'],
-        },
-        {
-          ab: [
-            '',
-            '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, ',
-          ]
-        },
-        {
-          ab: ['software distributed under the License is distributed on an '],
-        },
-        {
-          ab: [
-            '"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.',
-          ]
-        }
-      ]);
-    });
-
-    test('intraline normalization', function() {
-      // The content and highlights are in the format returned by the Gerrit
-      // REST API.
-      var content = [
-        '      <section class="summary">',
-        '        <gr-linked-text content="' +
-            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
-        '      </section>',
-      ];
-      var highlights = [
-        [31, 34], [42, 26]
-      ];
-      var results = GrDiffBuilder.prototype._normalizeIntralineHighlights(
-          content, highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 31,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 0,
-          endIndex: 33,
-        },
-        {
-          contentIndex: 1,
-          startIndex: 75,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 0,
-          endIndex: 6,
-        }
-      ]);
-
-      content = [
-        '        this._path = value.path;',
-        '',
-        '        // When navigating away from the page, there is a ' +
-          'possibility that the',
-        '        // patch number is no longer a part of the URL ' +
-          '(say when navigating to',
-        '        // the top-level change info view) and therefore ' +
-          'undefined in `params`.',
-        '        if (!this._patchRange.patchNum) {',
-      ];
-      highlights = [
-        [14, 17],
-        [11, 70],
-        [12, 67],
-        [12, 67],
-        [14, 29],
-      ];
-      results = GrDiffBuilder.prototype._normalizeIntralineHighlights(content,
-          highlights);
-      assert.deepEqual(results, [
-        {
-          contentIndex: 0,
-          startIndex: 14,
-          endIndex: 31,
-        },
-        {
-          contentIndex: 2,
-          startIndex: 8,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 3,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 4,
-          startIndex: 11,
-          endIndex: 78,
-        },
-        {
-          contentIndex: 5,
-          startIndex: 12,
-          endIndex: 41,
-        }
-      ]);
-    });
-
     suite('rendering', function() {
       var content;
       var outputEl;
 
-      setup(function() {
+      setup(function(done) {
         var prefs = {
           line_length: 10,
           show_tabs: true,
@@ -577,32 +248,38 @@
             ]
           },
         ];
-        outputEl = document.createElement('out');
-        builder =
-            new GrDiffBuilder(
-                {content: content}, {left: [], right: []}, prefs, outputEl);
-        builder.buildSectionElement = function(group) {
-          var section = document.createElement('stub');
-          section.textContent = group.lines.reduce(function(acc, line) {
-            return acc + line.text;
-          }, '');
-          return section;
-        };
-        builder.emitDiff();
+        element = fixture('basic');
+        outputEl = element.queryEffectiveChildren('#diffTable');
+        element.addEventListener('render', function() {
+          done();
+        });
+        sinon.stub(element, '_getDiffBuilder', function() {
+          var builder = new GrDiffBuilder(
+              {content: content}, {left: [], right: []}, prefs, outputEl);
+          builder.buildSectionElement = function(group) {
+            var section = document.createElement('stub');
+            section.textContent = group.lines.reduce(function(acc, line) {
+              return acc + line.text;
+            }, '');
+            return section;
+          };
+          return builder;
+        });
+        element.render({ content: content }, {left: [], right: []}, prefs);
       });
 
       test('renderSection', function() {
         var section = outputEl.querySelector('stub:nth-of-type(2)');
         var prevInnerHTML = section.innerHTML;
         section.innerHTML = 'wiped';
-        builder.renderSection(section);
+        element._builder.renderSection(section);
         section = outputEl.querySelector('stub:nth-of-type(2)');
         assert.equal(section.innerHTML, prevInnerHTML);
       });
 
       test('getSectionsByLineRange one line', function() {
         var section = outputEl.querySelector('stub:nth-of-type(2)');
-        var sections = builder.getSectionsByLineRange(1, 1, 'left');
+        var sections = element._builder.getSectionsByLineRange(1, 1, 'left');
         assert.equal(sections.length, 1);
         assert.strictEqual(sections[0], section);
       });
@@ -612,7 +289,7 @@
           outputEl.querySelector('stub:nth-of-type(2)'),
           outputEl.querySelector('stub:nth-of-type(3)'),
         ];
-        var sections = builder.getSectionsByLineRange(1, 2, 'left');
+        var sections = element._builder.getSectionsByLineRange(1, 2, 'left');
         assert.equal(sections.length, 2);
         assert.strictEqual(sections[0], section[0]);
         assert.strictEqual(sections[1], section[1]);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index c8ed041..bc3b23f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -20,7 +20,7 @@
 <dom-module id="gr-diff-highlight">
   <template>
     <style>
-      :host {
+      .contentWrapper ::content {
         position: relative;
       }
       .contentWrapper ::content .range {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index db092e6..1c3572d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -59,6 +59,11 @@
     },
 
     _enabledChanged: function() {
+      if (this.enabled) {
+        this.listen(document, 'selectionchange', '_handleSelectionChange');
+      } else {
+        this.unlisten(document, 'selectionchange', '_handleSelectionChange');
+      }
       for (var eventName in this._enabledListeners) {
         var methodName = this._enabledListeners[eventName];
         if (this.enabled) {
@@ -88,6 +93,14 @@
       }
     },
 
+    _handleSelectionChange: function() {
+      // Can't use up or down events to handle selection started and/or ended in
+      // in comment threads or outside of diff.
+      // Debounce removeActionBox to give it a chance to react to click/tap.
+      this._removeActionBoxDebounced();
+      this.debounce('selectionChange', this._handleSelection, 200);
+    },
+
     _handleRender: function() {
       this._applyAllHighlights();
     },
@@ -129,6 +142,111 @@
       }, this);
     },
 
+    /**
+     * Convert DOM Range selection to concrete numbers (line, column, side).
+     * Moves range end if it's not inside td.content.
+     * Returns null if selection end is not valid (outside of diff).
+     *
+     * @param {Node} node td.content child
+     * @param {number} offset offset within node
+     * @return {{
+     *   node: Node,
+     *   side: string,
+     *   line: Number,
+     *   column: Number
+     * }}
+     */
+    _normalizeSelectionSide: function(node, offset) {
+      var column;
+      if (!this.contains(node)) {
+        return;
+      }
+      var lineEl = this.diffBuilder.getLineElByChild(node);
+      if (!lineEl) {
+        return;
+      }
+      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      if (!side) {
+        return;
+      }
+      var line = this.diffBuilder.getLineNumberByChild(lineEl);
+      if (!line) {
+        return;
+      }
+      var content = this.diffBuilder.getContentByLineEl(lineEl);
+      if (!content) {
+        return;
+      }
+      if (!content.contains(node)) {
+        node = content;
+        column = 0;
+      } else {
+        var thread = content.querySelector('gr-diff-comment-thread');
+        if (thread && thread.contains(node)) {
+          column = this._getLength(content);
+          node = content;
+        } else {
+          column = this._convertOffsetToColumn(node, offset);
+        }
+      }
+
+      return {
+        node: node,
+        side: side,
+        line: line,
+        column: column,
+      };
+    },
+
+    _handleSelection: function() {
+      var selection = window.getSelection();
+      if (selection.rangeCount != 1) {
+        return;
+      }
+      var range = selection.getRangeAt(0);
+      if (range.collapsed) {
+        return;
+      }
+      var start =
+          this._normalizeSelectionSide(range.startContainer, range.startOffset);
+      if (!start) {
+        return;
+      }
+      var end =
+          this._normalizeSelectionSide(range.endContainer, range.endOffset);
+      if (!end) {
+        return;
+      }
+      if (start.side !== end.side ||
+          end.line < start.line ||
+          (start.line === end.line && start.column === end.column)) {
+        return;
+      }
+
+      // TODO (viktard): Drop empty first and last lines from selection.
+
+      var actionBox = document.createElement('gr-selection-action-box');
+      Polymer.dom(this.root).appendChild(actionBox);
+      actionBox.range = {
+        startLine: start.line,
+        startChar: start.column,
+        endLine: end.line,
+        endChar: end.column,
+      };
+      actionBox.side = start.side;
+      if (start.line === end.line) {
+        actionBox.placeAbove(range);
+      } else if (start.node instanceof Text) {
+        actionBox.placeAbove(start.node.splitText(start.column));
+        start.node.parentElement.normalize(); // Undo splitText from above.
+      } else if (start.node.classList.contains('content') &&
+                 start.node.firstChild) {
+        actionBox.placeAbove(start.node.firstChild);
+      } else {
+        actionBox.placeAbove(start.node);
+      }
+    },
+
     _renderCommentRange: function(comment, el) {
       var lineEl = this.diffBuilder.getLineElByChild(el);
       if (!lineEl) {
@@ -181,6 +299,10 @@
           range.endLine, range.endChar, side);
     },
 
+    _removeActionBoxDebounced: function() {
+      this.debounce('removeActionBox', this._removeActionBox, 10);
+    },
+
     _removeActionBox: function() {
       var actionBox = this.$$('gr-selection-action-box');
       if (actionBox) {
@@ -188,8 +310,24 @@
       }
     },
 
+    _convertOffsetToColumn: function(el, offset) {
+      if (el instanceof Element && el.classList.contains('content')) {
+        return offset;
+      }
+      while (el.previousSibling ||
+          !el.parentElement.classList.contains('content')) {
+        if (el.previousSibling) {
+          el = el.previousSibling;
+          offset += this._getLength(el);
+        } else {
+          el = el.parentElement;
+        }
+      }
+      return offset;
+    },
+
     /**
-     * Traverse diff content from right to left, call callback for each node.
+     * Traverse Element from right to left, call callback for each node.
      * Stops if callback returns true.
      *
      * @param {!Node} startNode
@@ -200,7 +338,9 @@
       var travelLeft = opt_flags && opt_flags.left;
       var node = startNode;
       while (node) {
-        if (node instanceof Element && node.tagName !== 'HL') {
+        if (node instanceof Element &&
+            node.tagName !== 'HL' &&
+            node.tagName !== 'SPAN') {
           break;
         }
         var nextNode = travelLeft ? node.previousSibling : node.nextSibling;
@@ -222,7 +362,6 @@
         node = node.firstChild;
         var length = 0;
         while (node) {
-          // Only measure Text nodes and <hl>
           if (node instanceof Text || node.tagName == 'HL') {
             length += this._getLength(node);
           }
@@ -242,10 +381,16 @@
      * @return {!Element} Wrapped node.
      */
     _wrapInHighlight: function(node, cssClass) {
-      var hl = document.createElement('hl');
-      hl.className = cssClass;
-      Polymer.dom(node.parentElement).replaceChild(hl, node);
-      hl.appendChild(node);
+      var hl;
+      if (node.tagName === 'HL') {
+        hl = node;
+        hl.classList.add(cssClass);
+      } else {
+        hl = document.createElement('hl');
+        hl.className = cssClass;
+        Polymer.dom(node.parentElement).replaceChild(hl, node);
+        hl.appendChild(node);
+      }
       return hl;
     },
 
@@ -256,7 +401,7 @@
      * @param {number} offset
      * @return {!Text} Trailing Text Node.
      */
-    _splitText: function(node, offset) {
+    _splitTextNode: function(node, offset) {
       if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
         // DOM Api for splitText() is broken for Unicode:
         // https://mathiasbynens.be/notes/javascript-unicode
@@ -275,10 +420,41 @@
     },
 
     /**
+     * Split Node at offset.
+     * If Node is Element, it's cloned and the node at offset is split too.
+     *
+     * @param {!Node} node
+     * @param {number} offset
+     * @return {!Node} Trailing Node.
+     */
+    _splitNode: function(element, offset) {
+      if (element instanceof Text) {
+        return this._splitTextNode(element, offset);
+      }
+      var tail = element.cloneNode(false);
+      element.parentElement.insertBefore(tail, element.nextSibling);
+      // Skip nodes before offset.
+      var node = element.firstChild;
+      while (node &&
+          this._getLength(node) <= offset ||
+          this._getLength(node) === 0) {
+        offset -= this._getLength(node);
+        node = node.nextSibling;
+      }
+      if (this._getLength(node) > offset) {
+        tail.appendChild(this._splitNode(node, offset));
+      }
+      while (node.nextSibling) {
+        tail.appendChild(node.nextSibling);
+      }
+      return tail;
+    },
+
+    /**
      * Split Text Node and wrap it in hl with cssClass.
      * Wraps trailing part after split, tailing one if opt_firstPart is true.
      *
-     * @param {!Text} node
+     * @param {!Node} node
      * @param {number} offset
      * @param {string} cssClass
      * @param {boolean=} opt_firstPart
@@ -288,10 +464,10 @@
         return this._wrapInHighlight(node, cssClass);
       } else {
         if (opt_firstPart) {
-          this._splitText(node, offset);
+          this._splitNode(node, offset);
           // Node points to first part of the Text, second one is sibling.
         } else {
-          node = this._splitText(node, offset);
+          node = this._splitNode(node, offset);
         }
         return this._wrapInHighlight(node, cssClass);
       }
@@ -329,29 +505,21 @@
       if (startNode instanceof Text) {
         startNode =
             this._splitAndWrapInHighlight(startNode, startOffset, cssClass);
-        startContent.insertBefore(startNode, startNode.nextSibling);
         // Edge case: single line, text node wraps the highlight.
         if (isOneLine && this._getLength(startNode) > length) {
-          var extra = this._splitText(startNode.firstChild, length);
+          var extra = this._splitTextNode(startNode.firstChild, length);
           startContent.insertBefore(extra, startNode.nextSibling);
           startContent.normalize();
         }
       } else if (startNode.tagName == 'HL') {
         if (!startNode.classList.contains(cssClass)) {
-          var hl = startNode;
-          startNode = this._splitAndWrapInHighlight(
-              startNode.firstChild, startOffset, cssClass);
-          startContent.insertBefore(startNode, hl.nextSibling);
           // Edge case: single line, <hl> wraps the highlight.
-          if (isOneLine && this._getLength(startNode) > length) {
-            var trailingHl = hl.cloneNode(false);
-            trailingHl.appendChild(
-                this._splitText(startNode.firstChild, length));
-            startContent.insertBefore(trailingHl, startNode.nextSibling);
+          // Should leave wrapping HL's content after the highlight.
+          if (isOneLine && startOffset + length < this._getLength(startNode)) {
+            this._splitNode(startNode, startOffset + length);
           }
-          if (hl.textContent.length === 0) {
-            hl.remove();
-          }
+          startNode =
+              this._splitAndWrapInHighlight(startNode, startOffset, cssClass);
         }
       } else {
         startNode = null;
@@ -393,8 +561,7 @@
           // Split text inside HL.
           var hl = endNode;
           endNode = this._splitAndWrapInHighlight(
-              endNode.firstChild, endOffset, cssClass, true);
-          endContent.insertBefore(endNode, hl);
+              endNode, endOffset, cssClass, true);
           if (hl.textContent.length === 0) {
             hl.remove();
           }
@@ -424,19 +591,32 @@
 
       // Grow starting highlight until endNode or end of line.
       if (startNode && startNode != endNode) {
-        this._traverseContentSiblings(startNode.nextSibling, function(node) {
-          startNode.textContent += node.textContent;
-          node.remove();
+        var growStartHl = function(node) {
+          if (node instanceof Text || node.tagName === 'SPAN') {
+            startNode.appendChild(node);
+          } else if (node.tagName === 'HL') {
+            this._traverseContentSiblings(node.firstChild, growStartHl);
+            node.remove();
+          }
           return node == endNode;
-        });
+        }.bind(this);
+        this._traverseContentSiblings(startNode.nextSibling, growStartHl);
+        startNode.normalize();
       }
 
       if (!isOneLine && endNode) {
+        var growEndHl = function(node) {
+          if (node instanceof Text || node.tagName === 'SPAN') {
+            endNode.insertBefore(node, endNode.firstChild);
+          } else if (node.tagName === 'HL') {
+            this._traverseContentSiblings(node.firstChild, growEndHl);
+            node.remove();
+          }
+        }.bind(this);
         // Prepend text up to line start to the ending highlight.
-        this._traverseContentSiblings(endNode.previousSibling, function(node) {
-          endNode.textContent = node.textContent + endNode.textContent;
-          node.remove();
-        }, {left: true});
+        this._traverseContentSiblings(
+          endNode.previousSibling, growEndHl, {left: true});
+        endNode.normalize();
       }
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 60bfb96..0df6a20 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -31,38 +31,74 @@
 
         <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="138"></td>
+            <td class="left lineNum" data-value="138">138</td>
             <td class="content both darkHighlight">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</td>
-            <td class="right lineNum" data-value="119"></td>
+            <td class="right lineNum" data-value="119">119</td>
             <td class="content both darkHighlight">[14] Nam cum ad me in Cumanum salutandi causa uterque venisset,</td>
           </tr>
         </tbody>
 
         <tbody class="section delta">
           <tr class="diff-row side-by-side" left-type="remove" right-type="add">
-            <td class="left lineNum" data-value="140"></td>
+            <td class="left lineNum" data-value="140">140</td>
             <!-- Next tag is formatted to eliminate zero-length text nodes. -->
-            <td class="content remove lightHighlight">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a udiam, <hl>quid</hl> sit, quod <hl>Epicurum</hl><gr-diff-comment-thread>
+            <td class="content remove lightHighlight">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab withIndicator" style="tab-size:8;"></span></hl>udiam, <hl>quid</hl> sit, <span class="tab withIndicator" style="tab-size:8;"></span>quod <hl>Epicurum</hl><gr-diff-comment-thread>
                 [Yet another random diff thread content here]
               </gr-diff-comment-thread></td>
-            <td class="right lineNum" data-value="121"></td>
-            <td class="content add lightHighlight">
-              nacti ,
-              <hl>,</hl>
-              sumus  otiosum,  audiam,  sit, quod
-            </td>
+            <td class="right lineNum" data-value="120">120</td>
+            <!-- Next tag is formatted to eliminate zero-length text nodes. -->
+            <td class="content add lightHighlight">nacti , <hl>,</hl> sumus <hl><span class="tab withIndicator" style="tab-size:8;"></span></hl> otiosum,  <span class="tab withIndicator" style="tab-size:8;"></span> audiam,  sit, quod</td>
           </tr>
         </tbody>
 
         <tbody class="section both">
           <tr class="diff-row side-by-side" left-type="both" right-type="both">
-            <td class="left lineNum" data-value="149"></td>
-            <td class="content both darkHighlight">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</td>
+            <td class="left lineNum" data-value="141"></td>
+            <td class="content both darkHighlight">nam et<hl><span class="tab withIndicator" style="tab-size:8;">	</span></hl>complectitur<span class="tab withIndicator" style="tab-size:8;">	</span>verbis, quod vult, et dicit plane, quod intellegam;</td>
             <td class="right lineNum" data-value="130"></td>
             <td class="content both darkHighlight">nam et complectitur verbis, quod vult, et dicit plane, quod intellegam;</td>
           </tr>
         </tbody>
 
+        <tbody class="section contextControl">
+          <tr class="diff-row side-by-side" left-type="contextControl" right-type="contextControl">
+            <td class="left contextLineNum" data-value="@@"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+            <td class="right contextLineNum" data-value="@@"></td>
+            <td>
+              <gr-button>+10↑</gr-button>
+              -
+              <gr-button>Show 21 common lines</gr-button>
+              -
+              <gr-button>+10↓</gr-button>
+            </td>
+          </tr>
+        </tbody>
+
+        <tbody class="section delta">
+          <tr class="diff-row side-by-side" left-type="blank" right-type="add">
+            <td class="left"></td>
+            <td class="blank darkHighlight"></td>
+            <td class="right lineNum" data-value="146"></td>
+            <td class="content add darkHighlight">[17] Quid igitur est? inquit; audire enim cupio, quid non probes. Principio, inquam,</td>
+          </tr>
+        </tbody>
+
+        <tbody class="section both">
+          <tr class="diff-row side-by-side" left-type="both" right-type="both">
+            <td class="left lineNum" data-value="165"></td>
+            <td class="content both darkHighlight">in physicis, quibus maxime gloriatur, primum totus est alienus. Democritea dicit</td>
+            <td class="right lineNum" data-value="147"></td>
+            <td class="content both darkHighlight">in physicis, <hl><span class="tab withIndicator" style="tab-size:8;">	</span></hl> quibus maxime gloriatur, primum totus est alienus. Democritea dicit</td>
+          </tr>
+        </tbody>
+
       </table>
     </gr-diff-highlight>
   </template>
@@ -116,6 +152,28 @@
       }
     });
 
+    test('does not listen to selectionchange when disabled', function() {
+      sandbox.stub(element, '_handleSelection');
+      sandbox.stub(element, '_removeActionBox');
+      element.enabled = false;
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+      element.flushDebouncer('selectionChange');
+      assert.isFalse(element._handleSelection.called);
+      element.flushDebouncer('removeActionBox');
+      assert.isFalse(element._removeActionBox.called);
+    });
+
+    test('listens to selectionchange when enabled', function() {
+      sandbox.stub(element, '_handleSelection');
+      sandbox.stub(element, '_removeActionBox');
+      element.enabled = true;
+      document.dispatchEvent(new CustomEvent('selectionchange'));
+      element.flushDebouncer('selectionChange');
+      assert.isTrue(element._handleSelection.called);
+      element.flushDebouncer('removeActionBox');
+      assert.isTrue(element._removeActionBox.called);
+    });
+
     suite('comment events', function() {
       var builder;
 
@@ -162,7 +220,6 @@
         });
       });
 
-
       test('renders lines in comment range on comment discard', function(done) {
         element.fire('comment-discard', {
           comment: {
@@ -254,10 +311,10 @@
       var diff = element.querySelector('#diffTable');
       var startContent =
           diff.querySelector('.left.lineNum[data-value="138"] ~ .content');
-      var endContent =
-          diff.querySelector('.left.lineNum[data-value="149"] ~ .content');
       var betweenContent =
           diff.querySelector('.left.lineNum[data-value="140"] ~ .content');
+      var endContent =
+          diff.querySelector('.left.lineNum[data-value="141"] ~ .content');
       var commentThread =
           diff.querySelector('gr-diff-comment-thread');
       var builder = {
@@ -271,9 +328,9 @@
       element.enabled = true;
       builder.getContentByLine.withArgs(138, 'left').returns(
           startContent);
-      builder.getContentByLine.withArgs(149, 'left').returns(
+      builder.getContentByLine.withArgs(141, 'left').returns(
           endContent);
-      element._applyRangedHighlight('some', 138, 4, 149, 8, 'left');
+      element._applyRangedHighlight('some', 138, 4, 141, 28, 'left');
       assert.instanceOf(startContent.childNodes[0], Text);
       assert.equal(startContent.childNodes[0].textContent, '[14]');
       assert.instanceOf(startContent.childNodes[1], Element);
@@ -282,14 +339,6 @@
       assert.equal(startContent.childNodes[1].tagName, 'HL');
       assert.equal(startContent.childNodes[1].className, 'some');
 
-      assert.instanceOf(endContent.childNodes[0], Element);
-      assert.equal(endContent.childNodes[0].textContent, 'nam et c');
-      assert.equal(endContent.childNodes[0].tagName, 'HL');
-      assert.equal(endContent.childNodes[0].className, 'some');
-      assert.instanceOf(endContent.childNodes[1], Text);
-      assert.equal(endContent.childNodes[1].textContent,
-          'omplectitur verbis, quod vult, et dicit plane, quod intellegam;');
-
       assert.instanceOf(betweenContent.firstChild, Element);
       assert.equal(betweenContent.firstChild.tagName, 'HL');
       assert.equal(betweenContent.firstChild.className, 'some');
@@ -304,6 +353,22 @@
 
       assert.strictEqual(betweenContent.querySelector('gr-diff-comment-thread'),
           commentThread, 'Comment threads should be preserved.');
+
+      assert.instanceOf(endContent.childNodes[0], Element);
+      assert.equal(endContent.childNodes[0].textContent,
+          'nam et\tcomplectitur\tverbis, ');
+      assert.equal(endContent.childNodes[0].tagName, 'HL');
+      assert.equal(endContent.childNodes[0].className, 'some');
+      assert.instanceOf(endContent.childNodes[1], Text);
+      assert.equal(endContent.childNodes[1].textContent,
+          'quod vult, et dicit plane, quod intellegam;');
+      var endHl = endContent.querySelector('hl.some');
+      assert.equal(endHl.childNodes.length, 5);
+      var tabs = endHl.querySelectorAll('span.tab');
+      assert.equal(tabs.length, 2);
+      assert.equal(tabs[0].previousSibling.textContent, 'nam et');
+      assert.equal(tabs[1].previousSibling.textContent, 'complectitur');
+      assert.equal(tabs[1].nextSibling.textContent, 'verbis, ');
     });
 
     suite('single line ranges', function() {
@@ -334,10 +399,18 @@
         assert.equal(content.firstChild.tagName, 'HL');
         assert.equal(content.firstChild.className, 'some');
         assert.equal(content.childNodes.length, 2);
-        assert.equal(content.firstChild.childNodes.length, 1);
+        assert.equal(content.firstChild.childNodes.length, 5);
         assert.equal(content.firstChild.textContent,
             'na💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
             'quid sit, quod Epicurum');
+        var tabs = content.querySelectorAll('span.tab');
+        assert.equal(tabs.length, 2);
+        assert.strictEqual(tabs[1].previousSibling, tabs[0].nextSibling);
+        assert.equal(tabs[0].previousSibling.textContent,
+            'na💢ti te, inquit, sumus aliquando otiosum, certe a ');
+        assert.equal(tabs[1].previousSibling.textContent,
+            'udiam, quid sit, ');
+        assert.equal(tabs[1].nextSibling.textContent, 'quod Epicurum');
       });
 
       test('merging multiple other hls', function() {
@@ -346,7 +419,8 @@
         assert.equal(content.childNodes.length, 4);
         var hl = content.querySelector('hl.some');
         assert.strictEqual(content.firstChild, hl.previousSibling);
-        assert.equal(hl.childNodes.length, 1);
+        assert.equal(hl.childNodes.length, 5);
+        assert.equal(content.querySelectorAll('span.tab').length, 2);
         assert.equal(hl.textContent,
             'a💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
             'quid sit, quod Epicuru');
@@ -375,7 +449,7 @@
         //  After: na💢ti <hl class="foo">te, in</hl><hl class="some">quit, ...
         element._applyRangedHighlight('some', 140, 12, 140, 21, 'left');
         var hl = content.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">quit, sum</hl>');
+        assert.equal(hl.textContent, 'quit, sum');
         assert.equal(
             hl.previousSibling.outerHTML, '<hl class="foo">te, in</hl>');
       });
@@ -385,7 +459,7 @@
         //  After: <hl class="foo">t</hl><hl="some">e, i</hl><hl class="foo">n..
         element._applyRangedHighlight('some', 140, 7, 140, 12, 'left');
         var hl = content.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">e, in</hl>');
+        assert.equal(hl.textContent, 'e, in');
         assert.equal(hl.previousSibling.outerHTML, '<hl class="foo">t</hl>');
         assert.equal(hl.nextSibling.outerHTML, '<hl class="foo">quit</hl>');
       });
@@ -393,7 +467,7 @@
       test('hl starts and ends in different hls', function() {
         element._applyRangedHighlight('some', 140, 8, 140, 27, 'left');
         var hl = content.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">, inquit, sumus ali</hl>');
+        assert.equal(hl.textContent, ', inquit, sumus ali');
         assert.equal(hl.previousSibling.outerHTML, '<hl class="foo">te</hl>');
         assert.equal(hl.nextSibling.outerHTML, '<hl class="bar">quando</hl>');
       });
@@ -408,9 +482,7 @@
       test('hl starting and ending in boundaries', function() {
         element._applyRangedHighlight('some', 140, 6, 140, 33, 'left');
         var hl = content.querySelector('hl.some');
-        assert.equal(
-            hl.outerHTML, '<hl class="some">te, inquit, sumus aliquando</hl>');
-        assert.notOk(content.querySelector('.foo'));
+        assert.equal(hl.textContent, 'te, inquit, sumus aliquando');
         assert.notOk(content.querySelector('.bar'));
       });
 
@@ -422,7 +494,7 @@
         assert.equal(hl.outerHTML, '<hl class="some">a💢t</hl>');
       });
 
-      test('growing hl left including another hl', function() {
+      test('growing hl right including another hl', function() {
         element._applyRangedHighlight('some', 140, 1, 140, 4, 'left');
         element._applyRangedHighlight('some', 140, 3, 140, 10, 'left');
         assert.equal(content.querySelectorAll('hl.some').length, 1);
@@ -431,7 +503,7 @@
         assert.equal(hl.nextSibling.outerHTML, '<hl class="foo">inquit</hl>');
       });
 
-      test('growing hl right to start of line', function() {
+      test('growing hl left to start of line', function() {
         element._applyRangedHighlight('some', 140, 2, 140, 5, 'left');
         element._applyRangedHighlight('some', 140, 0, 140, 3, 'left');
         assert.equal(content.querySelectorAll('hl.some').length, 1);
@@ -439,6 +511,14 @@
         assert.equal(hl.outerHTML, '<hl class="some">na💢ti</hl>');
         assert.strictEqual(content.firstChild, hl);
       });
+
+      test('splitting hl containing a tab', function() {
+        element._applyRangedHighlight('some', 140, 63, 140, 72, 'left');
+        assert.equal(content.querySelector('hl.some').textContent, 'sit, quod');
+        element._applyRangedHighlight('another', 140, 66, 140, 81, 'left');
+        assert.equal(content.querySelector('hl.another').textContent,
+            ', quod Epicurum');
+      });
     });
 
     test('_applyAllHighlights', function() {
@@ -499,5 +579,281 @@
       element.fire('show-context');
       assert.isFalse(element._applyAllHighlights.called);
     });
+
+    suite('selection', function() {
+      var diff;
+      var builder;
+      var contentStubs;
+
+      var stubContent = function(line, side, opt_child) {
+        var content = diff.querySelector(
+            '.' + side + '.lineNum[data-value="' + line + '"] ~ .content');
+        var lineEl = diff.querySelector(
+            '.' + side + '.lineNum[data-value="' + line + '"]');
+        contentStubs.push({
+          lineEl: lineEl,
+          content: content,
+        });
+        builder.getContentByLineEl.withArgs(lineEl).returns(content);
+        builder.getLineNumberByChild.withArgs(lineEl).returns(line);
+        builder.getContentByLine.withArgs(line, side).returns(content);
+        builder.getSideByLineEl.withArgs(lineEl).returns(side);
+        return content;
+      };
+
+      var emulateSelection = function(
+          startNode, startOffset, endNode, endOffset) {
+        var selection = window.getSelection();
+        var range = document.createRange();
+        range.setStart(startNode, startOffset);
+        range.setEnd(endNode, endOffset);
+        selection.addRange(range);
+        element._handleSelection();
+      };
+
+      var getActionRange = function() {
+        return Polymer.dom(element.root).querySelector(
+            'gr-selection-action-box').range;
+      };
+
+      var getActionSide = function() {
+        return Polymer.dom(element.root).querySelector(
+            'gr-selection-action-box').side;
+      };
+
+      var getLineElByChild = function(node) {
+        var stubs = contentStubs.find(function(stub) {
+          return stub.content.contains(node);
+        });
+        return stubs && stubs.lineEl;
+      };
+
+      setup(function() {
+        contentStubs = [];
+        stub('gr-selection-action-box', {
+          placeAbove: sandbox.stub(),
+        });
+        diff = element.querySelector('#diffTable');
+        builder = {
+          getContentByLine: sandbox.stub(),
+          getContentByLineEl: sandbox.stub(),
+          getLineElByChild: getLineElByChild,
+          getLineNumberByChild: sandbox.stub(),
+          getSideByLineEl: sandbox.stub(),
+        };
+        element._cachedDiffBuilder = builder;
+        element.enabled = true;
+      });
+
+      teardown(function() {
+        contentStubs = null;
+        window.getSelection().removeAllRanges();
+      });
+
+      test('single line', function() {
+        var content = stubContent(138, 'left');
+        emulateSelection(content.firstChild, 5, content.firstChild, 12);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 138,
+          startChar: 5,
+          endLine: 138,
+          endChar: 12,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('multiline', function() {
+        var startContent = stubContent(119, 'right');
+        var endContent = stubContent(120, 'right');
+        emulateSelection(
+            startContent.firstChild, 10, endContent.lastChild, 7);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 119,
+          startChar: 10,
+          endLine: 120,
+          endChar: 34,
+        });
+        assert.equal(getActionSide(), 'right');
+      });
+
+      test('multiline grow end highlight over tabs', function() {
+        var startContent = stubContent(119, 'right');
+        var endContent = stubContent(120, 'right');
+        emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 119,
+          startChar: 10,
+          endLine: 120,
+          endChar: 2,
+        });
+        assert.equal(getActionSide(), 'right');
+      });
+
+      test('collapsed', function() {
+        var content = stubContent(138, 'left');
+        emulateSelection(content.firstChild, 5, content.firstChild, 5);
+        assert.isOk(window.getSelection().getRangeAt(0).startContainer);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('starts inside hl', function() {
+        var content = stubContent(140, 'left');
+        var hl = content.querySelector('.foo');
+        emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 8,
+          endLine: 140,
+          endChar: 23,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('ends inside hl', function() {
+        var content = stubContent(140, 'left');
+        var hl = content.querySelector('.bar');
+        emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 18,
+          endLine: 140,
+          endChar: 27,
+        });
+      });
+
+      test('multiple hl', function() {
+        var content = stubContent(140, 'left');
+        var hl = content.querySelectorAll('hl')[4];
+        emulateSelection(content.firstChild, 2, hl.firstChild, 2);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 2,
+          endLine: 140,
+          endChar: 60,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('starts outside of diff', function() {
+        var content = stubContent(140, 'left');
+        emulateSelection(content.previousElementSibling.firstChild, 2,
+            content.firstChild, 2);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('ends outside of diff', function() {
+        var content = stubContent(140, 'left');
+        emulateSelection(content.nextElementSibling.firstChild, 2,
+            content.firstChild, 2);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('starts and ends on different sides', function() {
+        var startContent = stubContent(140, 'left');
+        var endContent = stubContent(130, 'right');
+        emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('starts in comment thread element', function() {
+        var startContent = stubContent(140, 'left');
+        var comment = startContent.querySelector('gr-diff-comment-thread');
+        var endContent = stubContent(141, 'left');
+        emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 81,
+          endLine: 141,
+          endChar: 4,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('ends in comment thread element', function() {
+        var content = stubContent(140, 'left');
+        var comment = content.querySelector('gr-diff-comment-thread');
+        emulateSelection(content.firstChild, 4, comment.firstChild, 1);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 4,
+          endLine: 140,
+          endChar: 81,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('starts in context element', function() {
+        var contextControl = diff.querySelector('.contextControl');
+        var content = stubContent(146, 'right');
+        emulateSelection(contextControl, 0, content.firstChild, 7);
+        // TODO (viktard): Select nearest line.
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('ends in context element', function() {
+        var contextControl = diff.querySelector('.contextControl');
+        var content = stubContent(141, 'left');
+        emulateSelection(content.firstChild, 2, contextControl, 0);
+        // TODO (viktard): Select nearest line.
+        assert.isFalse(element.isRangeSelected());
+      });
+
+      test('selection containing context element', function() {
+        var startContent = stubContent(130, 'right');
+        var endContent = stubContent(146, 'right');
+        emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 130,
+          startChar: 3,
+          endLine: 146,
+          endChar: 14,
+        });
+        assert.equal(getActionSide(), 'right');
+      });
+
+      test('ends at a tab', function() {
+        var content = stubContent(140, 'left');
+        emulateSelection(
+            content.firstChild, 1, content.querySelector('span'), 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 1,
+          endLine: 140,
+          endChar: 51,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      test('starts at a tab', function() {
+        var content = stubContent(140, 'left');
+        emulateSelection(
+            content.querySelectorAll('hl')[3], 0,
+            content.querySelectorAll('span')[1], 0);
+        assert.isTrue(element.isRangeSelected());
+        assert.deepEqual(getActionRange(), {
+          startLine: 140,
+          startChar: 51,
+          endLine: 140,
+          endChar: 68,
+        });
+        assert.equal(getActionSide(), 'left');
+      });
+
+      // TODO (viktard): Selection starts in line number.
+      // TODO (viktard): Empty lines in selection start.
+      // TODO (viktard): Empty lines in selection end.
+      // TODO (viktard): Only empty lines selected.
+      // TODO (viktard): Unified mode.
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
new file mode 100644
index 0000000..cae8bad
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.html
@@ -0,0 +1,23 @@
+<!--
+Copyright (C) 2016 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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-diff-processor">
+  <script src="../gr-diff/gr-diff-line.js"></script>
+  <script src="../gr-diff/gr-diff-group.js"></script>
+  <script src="gr-diff-processor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
new file mode 100644
index 0000000..0a54665
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -0,0 +1,411 @@
+// Copyright (C) 2016 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.
+(function() {
+  'use strict';
+
+  var WHOLE_FILE = -1;
+
+  var DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+  };
+
+  var DiffGroupType = {
+    ADDED: 'b',
+    BOTH: 'ab',
+    REMOVED: 'a',
+  };
+
+  var DiffHighlights = {
+    ADDED: 'edit_b',
+    REMOVED: 'edit_a',
+  };
+
+  Polymer({
+    is: 'gr-diff-processor',
+
+    properties: {
+
+      /**
+       * The amount of context around collapsed groups.
+       */
+      context: Number,
+
+      /**
+       * The array of groups output by the processor.
+       */
+      groups: {
+        type: Array,
+        notify: true,
+      },
+
+      /**
+       * Locations that should not be collapsed, including the locations of
+       * comments.
+       */
+      keyLocations: {
+        type: Object,
+        value: function() { return {left: {}, right: {}}; },
+      },
+    },
+
+    /**
+     * Asynchronously process the diff object into groups. As it processes, it
+     * will splice groups into the `groups` property of the component.
+     * @return {Promise} A promise that resolves when the diff is completely
+     *     processed.
+     */
+    process: function(content) {
+      return new Promise(function(resolve) {
+        this.groups = [];
+        this.push('groups', this._makeFileComments());
+
+        var state = {
+          lineNums: {left: 0, right: 0},
+          sectionIndex: 0,
+        };
+
+        content = this._splitCommonGroupsWithComments(content);
+
+        var nextStep = function() {
+          // If we are done, resolve the promise.
+          if (state.sectionIndex >= content.length) {
+            resolve(this.groups);
+            return;
+          }
+
+          // Process the next section and incorporate the result.
+          var result = this._processNext(state, content);
+          result.groups.forEach(function(group) {
+            this.push('groups', group);
+          }, this);
+          state.lineNums.left += result.lineDelta.left;
+          state.lineNums.right += result.lineDelta.right;
+
+          // Increment the index and recurse.
+          state.sectionIndex++;
+          this.async(nextStep, 1);
+        };
+
+        nextStep.call(this);
+      }.bind(this));
+    },
+
+    /**
+     * Process the next section of the diff.
+     */
+    _processNext: function(state, content) {
+      var section = content[state.sectionIndex];
+
+      var rows = {
+        both: section[DiffGroupType.BOTH] || null,
+        added: section[DiffGroupType.ADDED] || null,
+        removed: section[DiffGroupType.REMOVED] || null,
+      };
+
+      var highlights = {
+        added: section[DiffHighlights.ADDED] || null,
+        removed: section[DiffHighlights.REMOVED] || null,
+      };
+
+      if (rows.both) { // If it's a shared section.
+        var sectionEnd = null;
+        if (state.sectionIndex === 0) {
+          sectionEnd = 'first';
+        }
+        else if (state.sectionIndex === content.length - 1) {
+          sectionEnd = 'last';
+        }
+
+        var sharedGroups = this._sharedGroupsFromRows(
+            rows.both,
+            content.length > 1 ? this.context : WHOLE_FILE,
+            state.lineNums.left,
+            state.lineNums.right,
+            sectionEnd);
+
+        return {
+          lineDelta: {
+            left: rows.both.length,
+            right: rows.both.length,
+          },
+          groups: sharedGroups,
+        };
+      } else { // Otherwise it's a delta section.
+
+        var deltaGroup = this._deltaGroupFromRows(
+            rows.added,
+            rows.removed,
+            state.lineNums.left,
+            state.lineNums.right,
+            highlights);
+
+        return {
+          lineDelta: {
+            left: rows.removed ? rows.removed.length : 0,
+            right: rows.added ? rows.added.length : 0,
+          },
+          groups: [deltaGroup],
+        };
+      }
+    },
+
+    /**
+     * Take rows of a shared diff section and produce an array of corresponding
+     * (potentially collapsed) groups.
+     * @param  {Array<String>} rows
+     * @param  {Number} context
+     * @param  {Number} startLineNumLeft
+     * @param  {Number} startLineNumRight
+     * @param  {String} opt_sectionEnd String representing whether this is the
+     *     first section or the last section or neither. Use the values 'first',
+     *     'last' and null respectively.
+     * @return {Array<GrDiffGroup>}
+     */
+    _sharedGroupsFromRows: function(rows, context, startLineNumLeft,
+        startLineNumRight, opt_sectionEnd) {
+      var result = [];
+      var lines = [];
+      var line;
+
+      // Map each row to a GrDiffLine.
+      for (var i = 0; i < rows.length; i++) {
+        line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.text = rows[i];
+        line.beforeNumber = ++startLineNumLeft;
+        line.afterNumber = ++startLineNumRight;
+        lines.push(line);
+      }
+
+      // Find the hidden range based on the user's context preference. If this
+      // is the first or the last section of the diff, make sure the collapsed
+      // part of the section extends to the edge of the file.
+      var hiddenRange = [context, rows.length - context];
+      if (opt_sectionEnd === 'first') {
+        hiddenRange[0] = 0;
+      } else if (opt_sectionEnd === 'last') {
+        hiddenRange[1] = rows.length;
+      }
+
+      // If there is a range to hide.
+      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
+        var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+        var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+        var linesAfterCtx = lines.slice(hiddenRange[1]);
+
+        if (linesBeforeCtx.length > 0) {
+          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
+        }
+
+        var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+        ctxLine.contextGroup =
+            new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
+        result.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
+            [ctxLine]));
+
+        if (linesAfterCtx.length > 0) {
+          result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesAfterCtx));
+        }
+      } else {
+        result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, lines));
+      }
+
+      return result;
+    },
+
+    /**
+     * Take the rows of a delta diff section and produce the corresponding
+     * group.
+     * @param  {Array<String>} rowsAdded
+     * @param  {Array<String>} rowsRemoved
+     * @param  {Number} startLineNumLeft
+     * @param  {Number} startLineNumRight
+     * @return {GrDiffGroup}
+     */
+    _deltaGroupFromRows: function(rowsAdded, rowsRemoved, startLineNumLeft,
+        startLineNumRight, highlights) {
+      var lines = [];
+      if (rowsRemoved) {
+        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.REMOVE,
+            rowsRemoved, startLineNumLeft, highlights.removed));
+      }
+      if (rowsAdded) {
+        lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.ADD,
+            rowsAdded, startLineNumRight, highlights.added));
+      }
+      return new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
+    },
+
+    /**
+     * @return {Array<GrDiffLine>}
+     */
+    _deltaLinesFromRows: function(lineType, rows, startLineNum,
+        opt_highlights) {
+      // Normalize highlights if they have been passed.
+      if (opt_highlights) {
+        opt_highlights = this._normalizeIntralineHighlights(rows,
+            opt_highlights);
+      }
+
+      var lines = [];
+      var line;
+      for (var i = 0; i < rows.length; i++) {
+        line = new GrDiffLine(lineType);
+        line.text = rows[i];
+        if (lineType === GrDiffLine.Type.ADD) {
+          line.afterNumber = ++startLineNum;
+        } else {
+          line.beforeNumber = ++startLineNum;
+        }
+        if (opt_highlights) {
+          line.highlights = opt_highlights.filter(
+              function(hl) { return hl.contentIndex === i; });
+        }
+        lines.push(line);
+      }
+      return lines;
+    },
+
+    _makeFileComments: function() {
+      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      line.beforeNumber = GrDiffLine.FILE;
+      line.afterNumber = GrDiffLine.FILE;
+      return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
+    },
+
+    /**
+     * In order to show comments out of the bounds of the selected context,
+     * treat them as separate chunks within the model so that the content (and
+     * context surrounding it) renders correctly.
+     * @param  {Object} content The diff content object.
+     * @return {Object} A new diff content object with regions split up.
+     */
+    _splitCommonGroupsWithComments: function(content) {
+      var result = [];
+      var leftLineNum = 0;
+      var rightLineNum = 0;
+
+      // For each section in the diff.
+      for (var i = 0; i < content.length; i++) {
+
+        // If it isn't a common group, append it as-is and update line numbers.
+        if (!content[i].ab) {
+          result.push(content[i]);
+          if (content[i].a) {
+            leftLineNum += content[i].a.length;
+          }
+          if (content[i].b) {
+            rightLineNum += content[i].b.length;
+          }
+          continue;
+        }
+
+        var chunk = content[i].ab;
+        var currentChunk = {ab: []};
+
+        // For each line in the common group.
+        for (var j = 0; j < chunk.length; j++) {
+          leftLineNum++;
+          rightLineNum++;
+
+          // If this line should not be collapsed.
+          if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
+              this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
+
+            // If any lines have been accumulated into the chunk leading up to
+            // this non-collapse line, then add them as a chunk and start a new
+            // one.
+            if (currentChunk.ab && currentChunk.ab.length > 0) {
+              result.push(currentChunk);
+              currentChunk = {ab: []};
+            }
+
+            // Add the non-collapse line as its own chunk.
+            result.push({ab: [chunk[j]]});
+          } else {
+            // Append the current line to the current chunk.
+            currentChunk.ab.push(chunk[j]);
+          }
+        }
+
+        if (currentChunk.ab && currentChunk.ab.length > 0) {
+          result.push(currentChunk);
+        }
+      }
+
+      return result;
+    },
+
+    /**
+     * The `highlights` array consists of a list of <skip length, mark length>
+     * pairs, where the skip length is the number of characters between the
+     * end of the previous edit and the start of this edit, and the mark
+     * length is the number of edited characters following the skip. The start
+     * of the edits is from the beginning of the related diff content lines.
+     *
+     * Note that the implied newline character at the end of each line is
+     * included in the length calculation, and thus it is possible for the
+     * edits to span newlines.
+     *
+     * A line highlight object consists of three fields:
+     * - contentIndex: The index of the diffChunk `content` field (the line
+     *   being referred to).
+     * - startIndex: Where the highlight should begin.
+     * - endIndex: (optional) Where the highlight should end. If omitted, the
+     *   highlight is meant to be a continuation onto the next line.
+     */
+    _normalizeIntralineHighlights: function(content, highlights) {
+      var contentIndex = 0;
+      var idx = 0;
+      var normalized = [];
+      for (var i = 0; i < highlights.length; i++) {
+        var line = content[contentIndex] + '\n';
+        var hl = highlights[i];
+        var j = 0;
+        while (j < hl[0]) {
+          if (idx === line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        var lineHighlight = {
+          contentIndex: contentIndex,
+          startIndex: idx,
+        };
+
+        j = 0;
+        while (line && j < hl[1]) {
+          if (idx === line.length) {
+            idx = 0;
+            line = content[++contentIndex] + '\n';
+            normalized.push(lineHighlight);
+            lineHighlight = {
+              contentIndex: contentIndex,
+              startIndex: idx,
+            };
+            continue;
+          }
+          idx++;
+          j++;
+        }
+        lineHighlight.endIndex = idx;
+        normalized.push(lineHighlight);
+      }
+      return normalized;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
new file mode 100644
index 0000000..120c980
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -0,0 +1,515 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-diff-processor test</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-diff-processor.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-diff-processor></gr-diff-processor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff-processor tests', function() {
+    var WHOLE_FILE = -1;
+    var loremIpsum = 'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+        'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+        'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+        'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+        'fugit assum per.';
+
+    var element;
+
+    suite('not logged in', function() {
+
+      setup(function() {
+        element = fixture('basic');
+
+        element.context = 4;
+      });
+
+      test('process loaded content', function(done) {
+        var content = [
+          {
+            ab: [
+              '<!DOCTYPE html>',
+              '<meta charset="utf-8">',
+            ]
+          },
+          {
+            a: [
+              '  Welcome ',
+              '  to the wooorld of tomorrow!',
+            ],
+            b: [
+              '  Hello, world!',
+            ],
+          },
+          {
+            ab: [
+              'Leela: This is the only place the ship can’t hear us, so ',
+              'everyone pretend to shower.',
+              'Fry: Same as every day. Got it.',
+            ]
+          },
+        ];
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          assert.equal(groups.length, 4);
+
+          var group = groups[0];
+          assert.equal(group.type, GrDiffGroup.Type.BOTH);
+          assert.equal(group.lines.length, 1);
+          assert.equal(group.lines[0].text, '');
+          assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
+          assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
+
+          group = groups[1];
+          assert.equal(group.type, GrDiffGroup.Type.BOTH);
+          assert.equal(group.lines.length, 2);
+          assert.equal(group.lines.length, 2);
+
+          function beforeNumberFn(l) { return l.beforeNumber; }
+          function afterNumberFn(l) { return l.afterNumber; }
+          function textFn(l) { return l.text; }
+
+          assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
+          assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
+          assert.deepEqual(group.lines.map(textFn), [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ]);
+
+          group = groups[2];
+          assert.equal(group.type, GrDiffGroup.Type.DELTA);
+          assert.equal(group.lines.length, 3);
+          assert.equal(group.adds.length, 1);
+          assert.equal(group.removes.length, 2);
+          assert.deepEqual(group.removes.map(beforeNumberFn), [3, 4]);
+          assert.deepEqual(group.adds.map(afterNumberFn), [3]);
+          assert.deepEqual(group.removes.map(textFn), [
+            '  Welcome ',
+            '  to the wooorld of tomorrow!',
+          ]);
+          assert.deepEqual(group.adds.map(textFn), [
+            '  Hello, world!',
+          ]);
+
+          group = groups[3];
+          assert.equal(group.type, GrDiffGroup.Type.BOTH);
+          assert.equal(group.lines.length, 3);
+          assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
+          assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
+          assert.deepEqual(group.lines.map(textFn), [
+            'Leela: This is the only place the ship can’t hear us, so ',
+            'everyone pretend to shower.',
+            'Fry: Same as every day. Got it.',
+          ]);
+
+          done();
+        });
+      });
+
+      test('insert context groups', function(done) {
+        var content = [
+          {ab: []},
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: []},
+          {b: ['elgoog elgoog elgoog']},
+          {ab: []},
+        ];
+        for (var i = 0; i < 100; i++) {
+          content[0].ab.push('all work and no play make jack a dull boy');
+          content[4].ab.push('all work and no play make jill a dull girl');
+        }
+        for (var i = 0; i < 5; i++) {
+          content[2].ab.push('no tv and no beer make homer go crazy');
+        }
+
+        var context = 10;
+        element.context = context;
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[0].lines.length, 1);
+          assert.equal(groups[0].lines[0].text, '');
+          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
+          groups[1].lines[0].contextGroup.lines.forEach(function(l) {
+            assert.equal(l.text, content[0].ab[0]);
+          });
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, context);
+          groups[2].lines.forEach(function(l) {
+            assert.equal(l.text, content[0].ab[0]);
+          });
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[3].lines.length, 1);
+          assert.equal(groups[3].removes.length, 1);
+          assert.equal(groups[3].removes[0].text,
+              'all work and no play make andybons a dull boy');
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, 5);
+          groups[4].lines.forEach(function(l) {
+            assert.equal(l.text, content[2].ab[0]);
+          });
+
+          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[5].lines.length, 1);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+
+          assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[6].lines.length, context);
+          groups[6].lines.forEach(function(l) {
+            assert.equal(l.text, content[4].ab[0]);
+          });
+
+          assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
+          groups[7].lines[0].contextGroup.lines.forEach(function(l) {
+            assert.equal(l.text, content[4].ab[0]);
+          });
+
+          done();
+        });
+      });
+
+      test('insert context groups', function(done) {
+        content = [
+          {a: ['all work and no play make andybons a dull boy']},
+          {ab: []},
+          {b: ['elgoog elgoog elgoog']},
+        ];
+        for (var i = 0; i < 50; i++) {
+          content[1].ab.push('no tv and no beer make homer go crazy');
+        }
+
+        var context = 10;
+        element.context = context;
+
+        element.process(content).then(function() {
+          var groups = element.groups;
+
+          assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[0].lines.length, 1);
+          assert.equal(groups[0].lines[0].text, '');
+          assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
+          assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+
+          assert.equal(groups[1].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[1].lines.length, 1);
+          assert.equal(groups[1].removes.length, 1);
+          assert.equal(groups[1].removes[0].text,
+              'all work and no play make andybons a dull boy');
+
+          assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[2].lines.length, context);
+          groups[2].lines.forEach(function(l) {
+            assert.equal(l.text, content[1].ab[0]);
+          });
+
+          assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
+          assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
+          assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
+          groups[3].lines[0].contextGroup.lines.forEach(function(l) {
+            assert.equal(l.text, content[1].ab[0]);
+          });
+
+          assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+          assert.equal(groups[4].lines.length, context);
+          groups[4].lines.forEach(function(l) {
+            assert.equal(l.text, content[1].ab[0]);
+          });
+
+          assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+          assert.equal(groups[5].lines.length, 1);
+          assert.equal(groups[5].adds.length, 1);
+          assert.equal(groups[5].adds[0].text, 'elgoog elgoog elgoog');
+
+          done();
+        });
+      });
+
+      test('break up common diff chunks', function() {
+        element.keyLocations = {
+          left: {1: true},
+          right: {10: true},
+        };
+        var lineNums = {left: 0, right: 0};
+
+        var content = [
+          {
+            ab: [
+              'Copyright (C) 2015 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.',
+            ]
+          }
+        ];
+        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        assert.deepEqual(result, [
+          {
+            ab: ['Copyright (C) 2015 The Android Open Source Project'],
+          },
+          {
+            ab: [
+              '',
+              '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, ',
+            ]
+          },
+          {
+            ab: [
+                'software distributed under the License is distributed on an '],
+          },
+          {
+            ab: [
+              '"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.',
+            ]
+          }
+        ]);
+      });
+
+      test('intraline normalization', function() {
+        // The content and highlights are in the format returned by the Gerrit
+        // REST API.
+        var content = [
+          '      <section class="summary">',
+          '        <gr-linked-text content="' +
+              '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+          '      </section>',
+        ];
+        var highlights = [
+          [31, 34], [42, 26]
+        ];
+
+        var results = element._normalizeIntralineHighlights(content,
+            highlights);
+        assert.deepEqual(results, [
+          {
+            contentIndex: 0,
+            startIndex: 31,
+          },
+          {
+            contentIndex: 1,
+            startIndex: 0,
+            endIndex: 33,
+          },
+          {
+            contentIndex: 1,
+            startIndex: 75,
+          },
+          {
+            contentIndex: 2,
+            startIndex: 0,
+            endIndex: 6,
+          }
+        ]);
+
+        content = [
+          '        this._path = value.path;',
+          '',
+          '        // When navigating away from the page, there is a ' +
+            'possibility that the',
+          '        // patch number is no longer a part of the URL ' +
+            '(say when navigating to',
+          '        // the top-level change info view) and therefore ' +
+            'undefined in `params`.',
+          '        if (!this._patchRange.patchNum) {',
+        ];
+        highlights = [
+          [14, 17],
+          [11, 70],
+          [12, 67],
+          [12, 67],
+          [14, 29],
+        ];
+        results = element._normalizeIntralineHighlights(content, highlights);
+        assert.deepEqual(results, [
+          {
+            contentIndex: 0,
+            startIndex: 14,
+            endIndex: 31,
+          },
+          {
+            contentIndex: 2,
+            startIndex: 8,
+            endIndex: 78,
+          },
+          {
+            contentIndex: 3,
+            startIndex: 11,
+            endIndex: 78,
+          },
+          {
+            contentIndex: 4,
+            startIndex: 11,
+            endIndex: 78,
+          },
+          {
+            contentIndex: 5,
+            startIndex: 12,
+            endIndex: 41,
+          }
+        ]);
+      });
+
+      suite('gr-diff-processor helpers', function() {
+        var rows;
+
+        setup(function() {
+          rows = loremIpsum.split(' ');
+        });
+
+        test('_sharedGroupsFromRows WHOLE_FILE', function() {
+          var context = WHOLE_FILE;
+          var lineNumbers = {left: 10, right: 100};
+          var result = element._sharedGroupsFromRows(
+              rows, context, lineNumbers.left, lineNumbers.right, null);
+
+          // Results in one, uncollapsed group with all rows.
+          assert.equal(result.length, 1);
+          assert.equal(result[0].type, GrDiffGroup.Type.BOTH);
+          assert.equal(result[0].lines.length, rows.length);
+
+          // Line numbers are set correctly.
+          assert.equal(result[0].lines[0].beforeNumber, lineNumbers.left + 1);
+          assert.equal(result[0].lines[0].afterNumber, lineNumbers.right + 1);
+
+          assert.equal(result[0].lines[rows.length - 1].beforeNumber,
+              lineNumbers.left + rows.length);
+          assert.equal(result[0].lines[rows.length - 1].afterNumber,
+              lineNumbers.right + rows.length);
+        });
+
+        test('_sharedGroupsFromRows context', function() {
+          var context = 10;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100, null);
+          var expectedCollapseSize = rows.length - 2 * context;
+
+          assert.equal(result.length, 3, 'Results in three groups');
+
+          // The first and last are uncollapsed context, whereas the middle has
+          // a single context-control line.
+          assert.equal(result[0].lines.length, context);
+          assert.equal(result[1].lines.length, 1);
+          assert.equal(result[2].lines.length, context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result[1].lines[0].contextGroup.lines.length,
+              expectedCollapseSize);
+        });
+
+        test('_sharedGroupsFromRows first', function() {
+          var context = 10;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100, 'first');
+          var expectedCollapseSize = rows.length - context;
+
+          assert.equal(result.length, 2, 'Results in two groups');
+
+          // Only the first group is collapsed.
+          assert.equal(result[0].lines.length, 1);
+          assert.equal(result[1].lines.length, context);
+
+          // The collapsed group has the hidden lines as its context group.
+          assert.equal(result[0].lines[0].contextGroup.lines.length,
+              expectedCollapseSize);
+        });
+
+        test('_sharedGroupsFromRows few-rows', function() {
+          // Only ten rows.
+          rows = rows.slice(0, 10);
+          var context = 10;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100, 'first');
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.length, 1, 'Results in one group');
+          assert.equal(result[0].lines.length, rows.length);
+        });
+
+        test('_deltaLinesFromRows', function() {
+          var startLineNum = 10;
+          var result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
+              startLineNum);
+
+          assert.equal(result.length, rows.length);
+          assert.equal(result[0].type, GrDiffLine.Type.ADD);
+          assert.equal(result[0].afterNumber, startLineNum + 1);
+          assert.notOk(result[0].beforeNumber);
+          assert.equal(result[result.length - 1].afterNumber,
+              startLineNum + rows.length);
+          assert.notOk(result[result.length - 1].beforeNumber);
+
+          result = element._deltaLinesFromRows(GrDiffLine.Type.REMOVE, rows,
+              startLineNum);
+
+          assert.equal(result.length, rows.length);
+          assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
+          assert.equal(result[0].beforeNumber, startLineNum + 1);
+          assert.notOk(result[0].afterNumber);
+          assert.equal(result[result.length - 1].beforeNumber,
+              startLineNum + rows.length);
+          assert.notOk(result[result.length - 1].afterNumber);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 6f95789..9a8ea37 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -29,6 +29,7 @@
         cursor: pointer;
         padding: .3em;
         position: absolute;
+        white-space: nowrap;
       }
       .arrow {
         background: #fff;
@@ -36,6 +37,7 @@
         border-width: 0 1px 1px 0;
         height: var(--gr-arrow-size);
         left: calc(50% - 1em);
+        margin-top: .05em;
         position: absolute;
         transform: rotate(45deg);
         width: var(--gr-arrow-size);
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index f80f7ba..153db20 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -49,6 +49,7 @@
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-diff-highlight_test.html',
     'diff/gr-diff-preferences/gr-diff-preferences_test.html',
+    'diff/gr-diff-processor/gr-diff-processor_test.html',
     'diff/gr-diff-selection/gr-diff-selection_test.html',
     'diff/gr-diff-view/gr-diff-view_test.html',
     'diff/gr-diff/gr-diff-group_test.html',