Merge "doc: add option and example section to ssh index command"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index ef164bc..91bbbab 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -753,9 +753,6 @@
 
 Deletes the topic of a change.
 
-The request body does not need to include a link:#topic-input[
-TopicInput] entity if no review comment is added.
-
 Please note that some proxies prohibit request bodies for DELETE
 requests. In this case, if you want to specify a commit message, use
 link:#set-topic[PUT] to delete the topic.
@@ -2820,6 +2817,73 @@
 A review cannot be set on a change edit. Trying to post a review for a
 change edit fails with `409 Conflict`.
 
+It is also possible to add one or more reviewers to a change simultaneously
+with a review.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/review HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "Looks good to me, but Jane and John should also take a look.",
+    "labels": {
+      "Code-Review": 1
+    },
+    "reviewers": [
+      {
+        "reviewer": "jane.roe@example.com"
+      },
+      {
+        "reviewer": "john.doe@example.com"
+      }
+    ]
+  }
+----
+
+Each element of the `reviewers` list is an instance of
+link:#reviewer-input[ReviewerInput]. The corresponding result of
+adding each reviewer will be returned in a list of
+link:#add-reviewer-result[AddReviewerResult]. If there are any
+errors returned for reviewers, the entire review request will
+be rejected.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "labels": {
+      "Code-Review": 1
+    },
+    "reviewers": [
+      {
+        "input": "jane.roe@example.com",
+        "approvals": {
+          "Verified": " 0",
+          "Code-Review": " 0"
+        },
+        "_account_id": 1000097,
+        "name": "Jane Roe",
+        "email": "jane.roe@example.com"
+      },
+      {
+        "input": "john.doe@example.com",
+        "approvals": {
+          "Verified": " 0",
+          "Code-Review": " 0"
+        },
+        "_account_id": 1000096,
+        "name": "John Doe",
+        "email": "john.doe@example.com"
+      }
+    ]
+  }
+----
+
 [[rebase-revision]]
 === Rebase Revision
 --
@@ -3518,6 +3582,11 @@
 in the path name. This is useful to implement suggestion services
 finding a file by partial name.
 
+The integer-valued request parameter `parent` changes the response to return a
+list of the files which are different in this commit compared to the given
+parent commit. This is useful for supporting review of merge commits.  The value
+is the 1-based index of the parent's position in the commit object.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/files/?reviewed HTTP/1.0
@@ -3763,6 +3832,11 @@
 The `base` parameter can be specified to control the base patch set from which the diff should
 be generated.
 
+The integer-valued request parameter `parent` can be specified to control the
+parent commit number against which the diff should be generated.  This is useful
+for supporting review of merge commits.  The value is the 1-based index of the
+parent's position in the commit object.
+
 [[weblinks-only]]
 If the `weblinks-only` parameter is specified, only the diff web links are returned.
 
@@ -4060,6 +4134,11 @@
 |`reviewers`   |optional|
 The newly added reviewers as a list of link:#reviewer-info[
 ReviewerInfo] entities.
+|`ccs`         |optional|
+The newly CCed accounts as a list of link:#reviewer-info[
+ReviewerInfo] entities. This field will only appear if the requested
+`state` for the reviewer was `CC` *and* NoteDb is enabled on the
+server.
 |`error`       |optional|
 Error message explaining why the reviewer could not be added. +
 If a group was specified in the input and an error is returned, it
@@ -4312,6 +4391,9 @@
 The side on which the comment was added. +
 Allowed values are `REVISION` and `PARENT`. +
 If not set, the default is `REVISION`.
+|`parent`      |optional|
+The 1-based parent number. Used only for merge commits when `side == PARENT`.
+When not set the comment is for the auto-merge tree.
 |`line`        |optional|
 The number of the line for which the comment was done. +
 If range is set, this equals the end line of the range. +
@@ -4992,6 +5074,9 @@
 ID] of one group for which all members should be added as reviewers. +
 If an ID identifies both an account and a group, only the account is
 added as reviewer to the change.
+|`state`       |optional|
+Add reviewer in this state. Possible reviewer states are `REVIEWER`
+and `CC`. If not given, defaults to `REVIEWER`.
 |`confirmed`   |optional|
 Whether adding the reviewer is confirmed. +
 The Gerrit server may be configured to
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 14ca11d..b32fccc 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -18,10 +18,13 @@
 import static com.google.gerrit.acceptance.GitUtil.initSsh;
 import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.base.Function;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Chars;
@@ -77,9 +80,9 @@
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.FakeEmailSender;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gerrit.testutil.TempFileUtil;
 import com.google.gerrit.testutil.TestNotesMigration;
-import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
@@ -497,6 +500,29 @@
     return result;
   }
 
+  protected PushOneCommit.Result createMergeCommitChange(String ref)
+      throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result p1 = pushFactory.create(db, admin.getIdent(),
+        testRepo, "parent 1", ImmutableMap.of("foo", "foo-1", "bar", "bar-1"))
+        .to(ref);
+
+    // reset HEAD in order to create a sibling of the first change
+    testRepo.reset(initial);
+
+    PushOneCommit.Result p2 = pushFactory.create(db, admin.getIdent(),
+        testRepo, "parent 2", ImmutableMap.of("foo", "foo-2", "bar", "bar-2"))
+        .to(ref);
+
+    PushOneCommit m = pushFactory.create(db, admin.getIdent(), testRepo, "merge",
+        ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(p1.getCommit(), p2.getCommit()));
+    PushOneCommit.Result result = m.to(ref);
+    result.assertOkStatus();
+    return result;
+  }
+
   protected PushOneCommit.Result createDraftChange() throws Exception {
     return pushTo("refs/drafts/master");
   }
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
index a55acf2..c892877 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
@@ -44,6 +45,7 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
 
 import java.util.List;
+import java.util.Map;
 
 public class PushOneCommit {
   public static final String SUBJECT = "test commit";
@@ -91,6 +93,13 @@
         ReviewDb db,
         PersonIdent i,
         TestRepository<?> testRepo,
+        @Assisted String subject,
+        @Assisted Map<String, String> files);
+
+    PushOneCommit create(
+        ReviewDb db,
+        PersonIdent i,
+        TestRepository<?> testRepo,
         @Assisted("subject") String subject,
         @Assisted("fileName") String fileName,
         @Assisted("content") String content,
@@ -123,8 +132,7 @@
   private final TestRepository<?> testRepo;
 
   private final String subject;
-  private final String fileName;
-  private final String content;
+  private final Map<String, String> files;
   private String changeId;
   private Tag tag;
   private boolean force;
@@ -175,18 +183,43 @@
       @Assisted ReviewDb db,
       @Assisted PersonIdent i,
       @Assisted TestRepository<?> testRepo,
+      @Assisted String subject,
+      @Assisted Map<String, String> files) throws Exception {
+    this(notesFactory, approvalsUtil, queryProvider, db, i, testRepo,
+        subject, files, null);
+  }
+
+  @AssistedInject
+  PushOneCommit(ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      @Assisted ReviewDb db,
+      @Assisted PersonIdent i,
+      @Assisted TestRepository<?> testRepo,
       @Assisted("subject") String subject,
       @Assisted("fileName") String fileName,
       @Assisted("content") String content,
       @Nullable @Assisted("changeId") String changeId) throws Exception {
+    this(notesFactory, approvalsUtil, queryProvider, db, i, testRepo,
+        subject, ImmutableMap.of(fileName, content), changeId);
+  }
+
+  private PushOneCommit(ChangeNotes.Factory notesFactory,
+      ApprovalsUtil approvalsUtil,
+      Provider<InternalChangeQuery> queryProvider,
+      ReviewDb db,
+      PersonIdent i,
+      TestRepository<?> testRepo,
+      String subject,
+      Map<String, String> files,
+      String changeId) throws Exception {
     this.db = db;
     this.testRepo = testRepo;
     this.notesFactory = notesFactory;
     this.approvalsUtil = approvalsUtil;
     this.queryProvider = queryProvider;
     this.subject = subject;
-    this.fileName = fileName;
-    this.content = content;
+    this.files = files;
     this.changeId = changeId;
     if (changeId != null) {
       commitBuilder = testRepo.amendRef("HEAD")
@@ -212,12 +245,16 @@
   }
 
   public Result to(String ref) throws Exception {
-    commitBuilder.add(fileName, content);
+    for (Map.Entry<String, String> e : files.entrySet()) {
+      commitBuilder.add(e.getKey(), e.getValue());
+    }
     return execute(ref);
   }
 
   public Result rm(String ref) throws Exception {
-    commitBuilder.rm(fileName);
+    for (String fileName : files.keySet()) {
+      commitBuilder.rm(fileName);
+    }
     return execute(ref);
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 24cbac4..9533f3f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -526,6 +526,35 @@
   }
 
   @Test
+  public void filesOnMergeCommitChange() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+
+    // list files against auto-merge
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .files()
+        .keySet()
+      ).containsExactly(Patch.COMMIT_MSG, "foo", "bar");
+
+    // list files against parent 1
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .files(1)
+        .keySet()
+      ).containsExactly(Patch.COMMIT_MSG, "bar");
+
+    // list files against parent 2
+    assertThat(gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .files(2)
+        .keySet()
+      ).containsExactly(Patch.COMMIT_MSG, "foo");
+  }
+
+  @Test
   public void diff() throws Exception {
     PushOneCommit.Result r = createChange();
     DiffInfo diff = gApi.changes()
@@ -538,6 +567,48 @@
   }
 
   @Test
+  public void diffOnMergeCommitChange() throws Exception {
+    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+
+    DiffInfo diff;
+
+    // automerge
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("foo")
+        .diff();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("bar")
+        .diff();
+    assertThat(diff.metaA.lines).isEqualTo(5);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 1
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("bar")
+        .diff(1);
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+
+    // parent 2
+    diff = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .file("foo")
+        .diff(2);
+    assertThat(diff.metaA.lines).isEqualTo(1);
+    assertThat(diff.metaB.lines).isEqualTo(1);
+  }
+
+  @Test
   public void content() throws Exception {
     PushOneCommit.Result r = createChange();
     BinaryResult bin = gApi.changes()
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index e821608..af13c5d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -64,7 +64,7 @@
 
     String pushedRef = ref;
     if (!topic.isEmpty()) {
-      pushedRef += "/" + topic;
+      pushedRef += "/" + name(topic);
     }
     String refspec = "HEAD:" + pushedRef;
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 0bd70ef..49fb102 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -22,12 +24,20 @@
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.server.change.PostReviewers;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.testutil.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
 
 import org.junit.Test;
 
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.List;
 
 public class ChangeReviewersIT extends AbstractDaemonTest {
@@ -40,17 +50,14 @@
 
     int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
     int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
-    List<TestAccount> users = new ArrayList<>(largeGroupSize);
+    List<TestAccount> users =
+        createAccounts(largeGroupSize, "addGroupAsReviewer");
     List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
-    List<String> mediumGroupUsernames = new ArrayList<>(mediumGroupSize);
-    for (int i = 0; i < largeGroupSize; i++) {
-      users.add(accounts.create(name("u" + i), name("u" + i + "@example.com"),
-          "Full Name " + i));
-      largeGroupUsernames.add(users.get(i).username);
-      if (i < mediumGroupSize) {
-        mediumGroupUsernames.add(users.get(i).username);
-      }
+    for (TestAccount u : users) {
+      largeGroupUsernames.add(u.username);
     }
+    List<String> mediumGroupUsernames =
+        largeGroupUsernames.subList(0, mediumGroupSize);
     gApi.groups().id(largeGroup).addMembers(
         largeGroupUsernames.toArray(new String[largeGroupSize]));
     gApi.groups().id(mediumGroup).addMembers(
@@ -84,6 +91,311 @@
     assertThat(result.confirm).isNull();
     assertThat(result.error).isNull();
     assertThat(result.reviewers).hasSize(mediumGroupSize);
+
+    // Verify that group members were added as reviewers.
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, notesMigration.readChanges() ? REVIEWER : CC,
+        users.subList(0, mediumGroupSize));
+  }
+
+  @Test
+  public void addCcAccount() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    AddReviewerResult result = addReviewer(changeId, in);
+
+    assertThat(result.input).isEqualTo(user.email);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertThat(result.reviewers).isNull();
+      assertThat(result.ccs).hasSize(1);
+      AccountInfo ai = result.ccs.get(0);
+      assertThat(ai._accountId).isEqualTo(user.id.get());
+      assertReviewers(c, CC, user);
+    } else {
+      assertThat(result.ccs).isNull();
+      assertThat(result.reviewers).hasSize(1);
+      AccountInfo ai = result.reviewers.get(0);
+      assertThat(ai._accountId).isEqualTo(user.id.get());
+      assertReviewers(c, CC, user);
+    }
+
+    // Verify email was sent to CCed account.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    if (notesMigration.readChanges()) {
+      assertThat(m.body())
+          .contains(admin.fullName + " has uploaded a new change for review.");
+    } else {
+      assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+      assertThat(m.body()).contains("I'd like you to do a code review.");
+    }
+  }
+
+  @Test
+  public void addCcGroup() throws Exception {
+    List<TestAccount> users = createAccounts(6, "addCcGroup");
+    List<String> usernames = new ArrayList<>(6);
+    for (TestAccount u : users) {
+      usernames.add(u.username);
+    }
+
+    List<TestAccount> firstUsers = users.subList(0, 3);
+    List<String> firstUsernames = usernames.subList(0, 3);
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = createGroup("cc1");
+    in.state = CC;
+    gApi.groups().id(in.reviewer)
+        .addMembers(firstUsernames.toArray(new String[firstUsernames.size()]));
+    AddReviewerResult result = addReviewer(changeId, in);
+
+    assertThat(result.input).isEqualTo(in.reviewer);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    if (notesMigration.readChanges()) {
+      assertThat(result.reviewers).isNull();
+    } else {
+      assertThat(result.ccs).isNull();
+    }
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, CC, firstUsers);
+
+    // Verify emails were sent to each of the group's accounts.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    Message m = messages.get(0);
+    List<Address> expectedAddresses = new ArrayList<>(firstUsers.size());
+    for (TestAccount u : firstUsers) {
+      expectedAddresses.add(u.emailAddress);
+    }
+    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
+
+    // CC a group that overlaps with some existing reviewers and CCed accounts.
+    TestAccount reviewer = accounts.create(name("reviewer"),
+        "addCcGroup-reviewer@example.com", "Reviewer");
+    result = addReviewer(changeId, reviewer.username);
+    assertThat(result.error).isNull();
+    sender.clear();
+    in.reviewer = createGroup("cc2");
+    gApi.groups().id(in.reviewer)
+        .addMembers(usernames.toArray(new String[usernames.size()]));
+    gApi.groups().id(in.reviewer).addMembers(reviewer.username);
+    result = addReviewer(changeId, in);
+    assertThat(result.input).isEqualTo(in.reviewer);
+    assertThat(result.confirm).isNull();
+    assertThat(result.error).isNull();
+    c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertThat(result.ccs).hasSize(3);
+      assertThat(result.reviewers).isNull();
+      assertReviewers(c, REVIEWER, reviewer);
+      assertReviewers(c, CC, users);
+    } else {
+      assertThat(result.ccs).isNull();
+      assertThat(result.reviewers).hasSize(3);
+      List<TestAccount> expectedUsers = new ArrayList<>(users.size() + 2);
+      expectedUsers.addAll(users);
+      expectedUsers.add(reviewer);
+      assertReviewers(c, CC, expectedUsers);
+    }
+
+    messages = sender.getMessages();
+    assertThat(messages).hasSize(1);
+    m = messages.get(0);
+    expectedAddresses = new ArrayList<>(4);
+    for (int i = 0; i < 3; i++) {
+      expectedAddresses.add(users.get(users.size() - i - 1).emailAddress);
+    }
+    if (notesMigration.readChanges()) {
+      expectedAddresses.add(reviewer.emailAddress);
+    }
+    assertThat(m.rcpt()).containsExactlyElementsIn(expectedAddresses);
+  }
+
+  @Test
+  public void transitionCcToReviewer() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    AddReviewerInput in = new AddReviewerInput();
+    in.reviewer = user.email;
+    in.state = CC;
+    addReviewer(changeId, in);
+    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
+    assertReviewers(c, REVIEWER);
+    assertReviewers(c, CC, user);
+
+    in.state = REVIEWER;
+    addReviewer(changeId, in);
+    c = gApi.changes().id(r.getChangeId()).get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, user);
+      assertReviewers(c, CC);
+    } else {
+      // If NoteDb not enabled, should have had no effect.
+      assertReviewers(c, REVIEWER);
+      assertReviewers(c, CC, user);
+    }
+  }
+
+  @Test
+  public void reviewAndAddReviewers() throws Exception {
+    TestAccount observer = accounts.user2();
+    PushOneCommit.Result r = createChange();
+    ReviewInput input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(observer.email, CC, false);
+
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNotNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+
+    // Verify reviewer and CC were added. If not in NoteDb read mode, both
+    // parties will be returned as CCed.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, admin, user);
+      assertReviewers(c, CC, observer);
+    } else {
+      // In legacy mode, change owner should be the only reviewer.
+      assertReviewers(c, REVIEWER, admin);
+      assertReviewers(c, CC, user, observer);
+   }
+
+    // Verify emails were sent to added reviewers.
+    List<Message> messages = sender.getMessages();
+    assertThat(messages).hasSize(3);
+    // First email to user.
+    Message m = messages.get(0);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress);
+    assertThat(m.body()).contains("Hello " + user.fullName + ",\n");
+    assertThat(m.body()).contains("I'd like you to do a code review.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    // Second email to reviewer and observer.
+    m = messages.get(1);
+    if (notesMigration.readChanges()) {
+      assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
+      assertThat(m.body()).contains(admin.fullName + " has uploaded a new change for review.");
+    } else {
+      assertThat(m.rcpt()).containsExactly(observer.emailAddress);
+      assertThat(m.body()).contains("Hello " + observer.fullName + ",\n");
+      assertThat(m.body()).contains("I'd like you to do a code review.");
+    }
+
+    // Third email is review to user and observer.
+    m = messages.get(2);
+    assertThat(m.rcpt()).containsExactly(user.emailAddress, observer.emailAddress);
+    assertThat(m.body()).contains(admin.fullName + " has posted comments on this change.");
+    assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
+    assertThat(m.body()).contains("Patch Set 1: Code-Review+2\n");
+  }
+
+  @Test
+  public void reviewAndAddGroupReviewers() throws Exception {
+    int largeGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = PostReviewers.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    List<TestAccount> users =
+        createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
+    List<String> usernames = new ArrayList<>(largeGroupSize);
+    for (TestAccount u : users) {
+      usernames.add(u.username);
+    }
+
+    String largeGroup = createGroup("largeGroup");
+    String mediumGroup = createGroup("mediumGroup");
+    gApi.groups().id(largeGroup).addMembers(
+        usernames.toArray(new String[largeGroupSize]));
+    gApi.groups().id(mediumGroup).addMembers(
+        usernames.subList(0, mediumGroupSize)
+            .toArray(new String[mediumGroupSize]));
+
+    TestAccount observer = accounts.user2();
+    PushOneCommit.Result r = createChange();
+
+    // Attempt to add overly large group as reviewers.
+    ReviewInput input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(observer.email, CC, false)
+        .reviewer(largeGroup);
+    ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(3);
+    AddReviewerResult reviewerResult = result.reviewers.get(largeGroup);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isNull();
+    assertThat(reviewerResult.error).isNotNull();
+    assertThat(reviewerResult.error).contains("has too many members to add them all as reviewers");
+
+    // No labels should have changed, and no reviewers/CCs should have been added.
+    ChangeInfo c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(c.messages).hasSize(1);
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Attempt to add group large enough to require confirmation, without
+    // confirmation, as reviewers.
+    input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(observer.email, CC, false)
+        .reviewer(mediumGroup);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(3);
+    reviewerResult = result.reviewers.get(mediumGroup);
+    assertThat(reviewerResult).isNotNull();
+    assertThat(reviewerResult.confirm).isTrue();
+    assertThat(reviewerResult.error)
+        .contains("has " + mediumGroupSize + " members. Do you want to add them all"
+            + " as reviewers?");
+
+    // No labels should have changed, and no reviewers/CCs should have been added.
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(c.messages).hasSize(1);
+    assertThat(c.reviewers.get(REVIEWER)).isNull();
+    assertThat(c.reviewers.get(CC)).isNull();
+
+    // Retrying with confirmation should successfully approve and add reviewers/CCs.
+    input = ReviewInput.approve()
+        .reviewer(user.email)
+        .reviewer(mediumGroup, CC, true);
+    result = review(r.getChangeId(), r.getCommit().name(), input);
+    assertThat(result.labels).isNotNull();
+    assertThat(result.reviewers).isNotNull();
+    assertThat(result.reviewers).hasSize(2);
+
+    c = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(c.messages).hasSize(2);
+
+    if (notesMigration.readChanges()) {
+      assertReviewers(c, REVIEWER, admin, user);
+      assertReviewers(c, CC, users.subList(0, mediumGroupSize));
+    } else {
+      // If not in NoteDb mode, then user is returned with the CC group.
+      assertReviewers(c, REVIEWER, admin);
+      List<TestAccount> expectedCC = users.subList(0, mediumGroupSize);
+      expectedCC.add(user);
+      assertReviewers(c, CC, expectedCC);
+    }
   }
 
   private AddReviewerResult addReviewer(String changeId, String reviewer)
@@ -100,6 +412,12 @@
     return readContentFromJson(resp, AddReviewerResult.class);
   }
 
+  private ReviewResult review(String changeId, String revisionId, ReviewInput in) throws Exception {
+    RestResponse resp = adminRestSession.post(
+        "/changes/" + changeId + "/revisions/" + revisionId + "/review", in);
+    return readContentFromJson(resp, ReviewResult.class);
+  }
+
   private static <T> T readContentFromJson(RestResponse r, Class<T> clazz)
       throws Exception {
     r.assertOK();
@@ -107,4 +425,42 @@
     jsonReader.setLenient(true);
     return newGson().fromJson(jsonReader, clazz);
   }
+
+  private static void assertReviewers(ChangeInfo c, ReviewerState reviewerState,
+      TestAccount... accounts) throws Exception {
+    List<TestAccount> accountList = new ArrayList<>(accounts.length);
+    for (TestAccount a : accounts) {
+      accountList.add(a);
+    }
+    assertReviewers(c, reviewerState, accountList);
+  }
+
+  private static void assertReviewers(ChangeInfo c, ReviewerState reviewerState,
+      Iterable<TestAccount> accounts) throws Exception {
+    Collection<AccountInfo> actualAccounts = c.reviewers.get(reviewerState);
+    if (actualAccounts == null) {
+      assertThat(accounts.iterator().hasNext()).isFalse();
+      return;
+    }
+    assertThat(actualAccounts).isNotNull();
+    List<Integer> actualAccountIds = new ArrayList<>(actualAccounts.size());
+    for (AccountInfo account : actualAccounts) {
+      actualAccountIds.add(account._accountId);
+    }
+    List<Integer> expectedAccountIds = new ArrayList<>();
+    for (TestAccount account : accounts) {
+      expectedAccountIds.add(account.getId().get());
+    }
+    assertThat(actualAccountIds).containsExactlyElementsIn(expectedAccountIds);
+  }
+
+  private List<TestAccount> createAccounts(int n, String emailPrefix)
+      throws Exception {
+    List<TestAccount> result = new ArrayList<>(n);
+    for (int i = 0; i < n; i++) {
+      result.add(accounts.create(name("u" + i),
+          emailPrefix + "-" + i + "@example.com", "Full Name " + i));
+    }
+    return result;
+  }
 }
\ No newline at end of file
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
new file mode 100644
index 0000000..f52fccd
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/TopicIT.java
@@ -0,0 +1,40 @@
+// 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.acceptance.rest.change;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.RestResponse;
+
+import org.junit.Test;
+
+public class TopicIT extends AbstractDaemonTest {
+  @Test
+  public void topic() throws Exception {
+    Result result = createChange();
+    String endpoint = "/changes/" + result.getChangeId() + "/topic";
+    RestResponse response = adminRestSession.put(endpoint, "topic");
+    response.assertOK();
+
+    response = adminRestSession.delete(endpoint);
+    response.assertNoContent();
+
+    response = adminRestSession.put(endpoint, "topic");
+    response.assertOK();
+
+    response = adminRestSession.put(endpoint, "");
+    response.assertNoContent();
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 96a672f..d9f1a5c 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 
+import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -56,6 +57,7 @@
 
 @NoHttpd
 public class CommentsIT extends AbstractDaemonTest {
+
   @Inject
   private Provider<ChangesCollection> changes;
 
@@ -87,12 +89,35 @@
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      String path = "file1";
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       assertThat(result).hasSize(1);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
+    }
+  }
+
+  @Test
+  public void createDraftOnMergeCommitChange() throws Exception {
+    for (Integer line : lines) {
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      String path = "file1";
+      DraftInput c1 = newDraft(path, Side.REVISION, line, "ps-1");
+      DraftInput c2 = newDraft(path, Side.PARENT, line, "auto-merge of ps-1");
+      DraftInput c3 = newDraftOnParent(path, 1, line, "parent-1 of ps-1");
+      DraftInput c4 = newDraftOnParent(path, 2, line, "parent-2 of ps-1");
+      addDraft(changeId, revId, c1);
+      addDraft(changeId, revId, c2);
+      addDraft(changeId, revId, c3);
+      addDraft(changeId, revId, c4);
+      Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
+      assertThat(result).hasSize(1);
+      assertThat(Lists.transform(result.get(path), infoToDraft(path)))
+          .containsExactly(c1, c2, c3, c4);
     }
   }
 
@@ -114,8 +139,31 @@
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
-      assertCommentInfo(actual, getPublishedComment(changeId, revId, actual.id));
+      assertThat(comment).isEqualTo(infoToInput(file).apply(actual));
+      assertThat(comment).isEqualTo(infoToInput(file).apply(
+          getPublishedComment(changeId, revId, actual.id)));
+    }
+  }
+
+  @Test
+  public void postCommentOnMergeCommitChange() throws Exception {
+    for (Integer line : lines) {
+      final String file = "/COMMIT_MSG";
+      PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
+      String changeId = r.getChangeId();
+      String revId = r.getCommit().getName();
+      ReviewInput input = new ReviewInput();
+      CommentInput c1 = newComment(file, Side.REVISION, line, "ps-1");
+      CommentInput c2 = newComment(file, Side.PARENT, line, "auto-merge of ps-1");
+      CommentInput c3 = newCommentOnParent(file, 1, line, "parent-1 of ps-1");
+      CommentInput c4 = newCommentOnParent(file, 2, line, "parent-2 of ps-1");
+      input.comments = new HashMap<>();
+      input.comments.put(file, ImmutableList.of(c1, c2, c3, c4));
+      revision(r).review(input);
+      Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+      assertThat(result).isNotEmpty();
+      assertThat(Lists.transform(result.get(file), infoToInput(file)))
+          .containsExactly(c1, c2, c3, c4);
     }
   }
 
@@ -129,7 +177,7 @@
     String revId = r.getCommit().getName();
     assertThat(getPublishedComments(changeId, revId)).isEmpty();
 
-    List<Comment> expectedComments = new ArrayList<>();
+    List<CommentInput> expectedComments = new ArrayList<>();
     for (Integer line : lines) {
       ReviewInput input = new ReviewInput();
       CommentInput comment = newComment(file, Side.REVISION, line, "comment " + line);
@@ -142,10 +190,8 @@
     Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(actualComments).hasSize(expectedComments.size());
-    for (int i = 0; i < actualComments.size(); i++) {
-      assertCommentInfo(expectedComments.get(i), actualComments.get(i));
-    }
+    assertThat(Lists.transform(actualComments, infoToInput(file)))
+        .containsExactlyElementsIn(expectedComments);
   }
 
   @Test
@@ -155,17 +201,18 @@
       Timestamp origLastUpdated = r.getChange().change().getLastUpdatedOn();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
-      DraftInput comment = newDraft("file1", Side.REVISION, line, "comment 1");
+      String path = "file1";
+      DraftInput comment = newDraft(path, Side.REVISION, line, "comment 1");
       addDraft(changeId, revId, comment);
       Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
       String uuid = actual.id;
       comment.message = "updated comment 1";
       updateDraft(changeId, revId, comment, uuid);
       result = getDraftComments(changeId, revId);
       actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
 
       // Posting a draft comment doesn't cause lastUpdatedOn to change.
       assertThat(r.getChange().change().getLastUpdatedOn())
@@ -181,7 +228,7 @@
     String revId = r.getCommit().getName();
     assertThat(getDraftComments(changeId, revId)).isEmpty();
 
-    List<Comment> expectedDrafts = new ArrayList<>();
+    List<DraftInput> expectedDrafts = new ArrayList<>();
     for (Integer line : lines) {
       DraftInput comment = newDraft(file, Side.REVISION, line, "comment " + line);
       expectedDrafts.add(comment);
@@ -191,10 +238,8 @@
     Map<String, List<CommentInfo>> result = getDraftComments(changeId, revId);
     assertThat(result).isNotEmpty();
     List<CommentInfo> actualComments = result.get(file);
-    assertThat(actualComments).hasSize(expectedDrafts.size());
-    for (int i = 0; i < actualComments.size(); i++) {
-      assertCommentInfo(expectedDrafts.get(i), actualComments.get(i));
-    }
+    assertThat(Lists.transform(actualComments, infoToDraft(file)))
+        .containsExactlyElementsIn(expectedDrafts);
   }
 
   @Test
@@ -203,11 +248,12 @@
       PushOneCommit.Result r = createChange();
       String changeId = r.getChangeId();
       String revId = r.getCommit().getName();
+      String path = "file1";
       DraftInput comment = newDraft(
-          "file1", Side.REVISION, line, "comment 1");
+          path, Side.REVISION, line, "comment 1");
       CommentInfo returned = addDraft(changeId, revId, comment);
       CommentInfo actual = getDraftComment(changeId, revId, returned.id);
-      assertCommentInfo(comment, actual);
+      assertThat(comment).isEqualTo(infoToDraft(path).apply(actual));
     }
   }
 
@@ -257,7 +303,9 @@
       Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
       assertThat(result).isNotEmpty();
       CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
-      assertCommentInfo(comment, actual);
+      CommentInput ci = infoToInput(file).apply(actual);
+      ci.updated = comment.updated;
+      assertThat(comment).isEqualTo(ci);
       assertThat(actual.updated)
           .isEqualTo(gApi.changes().id(r.getChangeId()).info().created);
 
@@ -597,45 +645,35 @@
     return gApi.changes().id(changeId).revision(revId).draft(uuid).get();
   }
 
-  private static void assertCommentInfo(Comment expected, CommentInfo actual) {
-    assertThat(actual.line).isEqualTo(expected.line);
-    assertThat(actual.message).isEqualTo(expected.message);
-    assertThat(actual.inReplyTo).isEqualTo(expected.inReplyTo);
-    assertCommentRange(expected.range, actual.range);
-    if (actual.side == null && expected.side != null) {
-      assertThat(Side.REVISION).isEqualTo(expected.side);
-    }
-  }
-
-  private static void assertCommentRange(Comment.Range expected,
-      Comment.Range actual) {
-    if (expected == null) {
-      assertThat(actual).isNull();
-    } else {
-      assertThat(actual).isNotNull();
-      assertThat(actual.startLine).isEqualTo(expected.startLine);
-      assertThat(actual.startCharacter).isEqualTo(expected.startCharacter);
-      assertThat(actual.endLine).isEqualTo(expected.endLine);
-      assertThat(actual.endCharacter).isEqualTo(expected.endCharacter);
-    }
-  }
-
   private static CommentInput newComment(String path, Side side, int line,
       String message) {
     CommentInput c = new CommentInput();
-    return populate(c, path, side, line, message);
+    return populate(c, path, side, null, line, message);
+  }
+
+  private static CommentInput newCommentOnParent(String path, int parent,
+      int line, String message) {
+    CommentInput c = new CommentInput();
+    return populate(c, path, Side.PARENT, Integer.valueOf(parent), line, message);
   }
 
   private DraftInput newDraft(String path, Side side, int line,
       String message) {
     DraftInput d = new DraftInput();
-    return populate(d, path, side, line, message);
+    return populate(d, path, side, null, line, message);
+  }
+
+  private DraftInput newDraftOnParent(String path, int parent, int line,
+      String message) {
+    DraftInput d = new DraftInput();
+    return populate(d, path, Side.PARENT, Integer.valueOf(parent), line, message);
   }
 
   private static <C extends Comment> C populate(C c, String path, Side side,
-      int line, String message) {
+      Integer parent, int line, String message) {
     c.path = path;
     c.side = side;
+    c.parent = parent;
     c.line = line != 0 ? line : null;
     c.message = message;
     if (line != 0) {
@@ -648,4 +686,38 @@
     }
     return c;
   }
+
+  private static Function<CommentInfo, CommentInput> infoToInput(
+      final String path) {
+    return new Function<CommentInfo, CommentInput>() {
+      @Override
+      public CommentInput apply(CommentInfo info) {
+        CommentInput ci = new CommentInput();
+        ci.path = path;
+        copy(info, ci);
+        return ci;
+      }
+    };
+  }
+
+  private static Function<CommentInfo, DraftInput> infoToDraft(
+      final String path) {
+    return new Function<CommentInfo, DraftInput>() {
+      @Override
+      public DraftInput apply(CommentInfo info) {
+        DraftInput di = new DraftInput();
+        di.path = path;
+        copy(info, di);
+        return di;
+      }
+    };
+  }
+
+  private static void copy(Comment from, Comment to) {
+    to.side = from.side == null ? Side.REVISION : from.side;
+    to.parent = from.parent;
+    to.line = from.line;
+    to.message = from.message;
+    to.range = from.range;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index f846151..b88278e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -14,19 +14,22 @@
 
 package com.google.gerrit.acceptance.server.change;
 
-import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
-import static com.google.gerrit.testutil.TestChanges.newChange;
+import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
+import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
 import static com.google.gerrit.testutil.TestChanges.newPatchSet;
 import static java.util.Collections.singleton;
 
-import com.google.common.base.Function;
-import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.FixInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ProblemInfo;
@@ -34,22 +37,39 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ConsistencyChecker;
-import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.Before;
 import org.junit.Test;
 
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 
 @NoHttpd
@@ -58,25 +78,36 @@
   private ChangeControl.GenericFactory changeControlFactory;
 
   @Inject
-  private ChangeUpdate.Factory changeUpdateFactory;
-
-  @Inject
   private Provider<ConsistencyChecker> checkerProvider;
 
   @Inject
   private IdentifiedUser.GenericFactory userFactory;
 
+  @Inject
+  private BatchUpdate.Factory updateFactory;
+
+  @Inject
+  private ChangeInserter.Factory changeInserterFactory;
+
+  @Inject
+  private PatchSetInserter.Factory patchSetInserterFactory;
+
+  @Inject
+  private ChangeNoteUtil noteUtil;
+
+  @Inject
+  @AnonymousCowardName
+  private String anonymousCowardName;
+
+  @Inject
+  private Sequences sequences;
+
   private RevCommit tip;
   private Account.Id adminId;
   private ConsistencyChecker checker;
 
   @Before
   public void setUp() throws Exception {
-    // TODO(dborowitz): Re-enable when ConsistencyChecker supports NoteDb.
-    // Note that we *do* want to enable these tests with GERRIT_CHECK_NOTEDB, as
-    // we need to be able to convert old, corrupt changes. However, those tests
-    // don't necessarily need to pass.
-    assume().that(notesMigration.enabled()).isFalse();
     // Ignore client clone of project; repurpose as server-side TestRepository.
     testRepo = new TestRepository<>(
         (InMemoryRepository) repoManager.openRepository(project));
@@ -88,62 +119,56 @@
 
   @Test
   public void validNewChange() throws Exception {
-    Change c = insertChange();
-    insertPatchSet(c);
-    incrementPatchSet(c);
-    insertPatchSet(c);
-    assertProblems(c);
+    assertNoProblems(insertChange(), null);
   }
 
   @Test
   public void validMergedChange() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    insertPatchSet(c);
-    incrementPatchSet(c);
-
-    incrementPatchSet(c);
-    RevCommit commit2 = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps2 = newPatchSet(c.currentPatchSetId(), commit2, adminId);
-    db.patchSets().insert(singleton(ps2));
-
-    testRepo.branch(c.getDest().get()).update(commit2);
-    assertProblems(c);
+    ChangeControl ctl = mergeChange(incrementPatchSet(insertChange()));
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void missingOwner() throws Exception {
-    Change c = newChange(project, new Account.Id(2));
-    db.changes().insert(singleton(c));
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    db.patchSets().insert(singleton(ps));
+    TestAccount owner = accounts.create("missing");
+    ChangeControl ctl = insertChange(owner);
+    db.accounts().deleteKeys(singleton(owner.getId()));
 
-    assertProblems(c, "Missing change owner: 2");
+    assertProblems(ctl, null,
+        problem("Missing change owner: " + owner.getId()));
   }
 
   @Test
   public void missingRepo() throws Exception {
-    Change c = newChange(new Project.NameKey("otherproject"), adminId);
-    db.changes().insert(singleton(c));
-    insertMissingPatchSet(c, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertProblems(c, "Destination repository not found: otherproject");
+    // NoteDb can't have a change without a repo.
+    assume().that(notesMigration.enabled()).isFalse();
+
+    ChangeControl ctl = insertChange();
+    Project.NameKey name = ctl.getProject().getNameKey();
+    ((InMemoryRepositoryManager) repoManager).deleteRepository(name);
+
+    assertProblems(
+        ctl, null,
+        problem("Destination repository not found: " + name));
   }
 
   @Test
   public void invalidRevision() throws Exception {
-    Change c = insertChange();
+    // NoteDb always parses the revision when inserting a patch set, so we can't
+    // create an invalid patch set.
+    assume().that(notesMigration.enabled()).isFalse();
 
-    db.patchSets().insert(singleton(newPatchSet(c.currentPatchSetId(),
-            "fooooooooooooooooooooooooooooooooooooooo", adminId)));
-    incrementPatchSet(c);
-    insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps = newPatchSet(
+        ctl.getChange().currentPatchSetId(),
+        "fooooooooooooooooooooooooooooooooooooooo",
+        adminId);
+    db.patchSets().update(singleton(ps));
 
-    assertProblems(c,
-        "Invalid revision on patch set 1:"
-        + " fooooooooooooooooooooooooooooooooooooooo");
+    assertProblems(
+        ctl, null,
+        problem("Invalid revision on patch set 1:"
+            + " fooooooooooooooooooooooooooooooooooooooo"));
   }
 
   // No test for ref existing but object missing; InMemoryRepository won't let
@@ -151,414 +176,429 @@
 
   @Test
   public void patchSetObjectAndRefMissing() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(),
-        ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), adminId);
-    db.patchSets().insert(singleton(ps));
-
-    assertProblems(c,
-        "Ref missing: " + ps.getId().toRefName(),
-        "Object missing: patch set 1: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    ChangeControl ctl = insertChange();
+    PatchSet ps = insertMissingPatchSet(ctl, rev);
+    ctl = reload(ctl);
+    assertProblems(
+        ctl, null,
+        problem("Ref missing: " + ps.getId().toRefName()),
+        problem(
+            "Object missing: patch set 2:"
+            + " deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
   }
 
   @Test
   public void patchSetObjectAndRefMissingWithFix() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(),
-        ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"), adminId);
-    db.patchSets().insert(singleton(ps));
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    ChangeControl ctl = insertChange();
+    PatchSet ps = insertMissingPatchSet(ctl, rev);
+    ctl = reload(ctl);
 
     String refName = ps.getId().toRefName();
-    List<ProblemInfo> problems = checker.check(c, new FixInput()).problems();
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + refName);
-    assertThat(p.status).isNull();
+    assertProblems(
+        ctl, new FixInput(),
+        problem("Ref missing: " + refName),
+        problem("Object missing: patch set 2: " + rev));
   }
 
   @Test
   public void patchSetRefMissing() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = insertPatchSet(c);
-    String refName = ps.getId().toRefName();
-    testRepo.update("refs/other/foo", ObjectId.fromString(ps.getRevision().get()));
+    ChangeControl ctl = insertChange();
+    testRepo.update(
+        "refs/other/foo",
+        ObjectId.fromString(
+            psUtil.current(db, ctl.getNotes()).getRevision().get()));
+    String refName = ctl.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
-    assertProblems(c, "Ref missing: " + refName);
+    assertProblems(ctl, null, problem("Ref missing: " + refName));
   }
 
   @Test
   public void patchSetRefMissingWithFix() throws Exception {
-    Change c = insertChange();
-    PatchSet ps = insertPatchSet(c);
-    String refName = ps.getId().toRefName();
-    testRepo.update("refs/other/foo", ObjectId.fromString(ps.getRevision().get()));
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.update("refs/other/foo", ObjectId.fromString(rev));
+    String refName = ctl.getChange().currentPatchSetId().toRefName();
     deleteRef(refName);
 
-    List<ProblemInfo> problems = checker.check(c, new FixInput()).problems();
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + refName);
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Repaired patch set ref");
-
+    assertProblems(
+        ctl, new FixInput(),
+        problem("Ref missing: " + refName, FIXED, "Repaired patch set ref"));
     assertThat(testRepo.getRepository().exactRef(refName).getObjectId().name())
-        .isEqualTo(ps.getRevision().get());
+        .isEqualTo(rev);
   }
 
   @Test
   public void patchSetObjectAndRefMissingWithDeletingPatchSet()
       throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertPatchSet(c);
-    incrementPatchSet(c);
-    PatchSet ps2 = insertMissingPatchSet(c,
-        "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
+
+    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps2 = insertMissingPatchSet(ctl, rev2);
+    ctl = reload(ctl);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(2);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps2.getId().toRefName());
-    assertThat(p.status).isNull();
-    p = problems.get(1);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Deleted patch set");
+    assertProblems(
+        ctl, fix,
+        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Object missing: patch set 2: " + rev2,
+            FIXED, "Deleted patch set"));
 
-    c = notesFactory.createChecked(db, c).getChange();
-    assertThat(c.currentPatchSetId().get()).isEqualTo(1);
-    assertThat(getPatchSet(ps1.getId())).isNotNull();
-    assertThat(getPatchSet(ps2.getId())).isNull();
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.get(db, ctl.getNotes(), ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps2.getId())).isNull();
   }
 
   @Test
   public void patchSetMultipleObjectsMissingWithDeletingPatchSets()
       throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
 
-    incrementPatchSet(c);
-    PatchSet ps2 = insertMissingPatchSet(c,
-        "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    String rev2 = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps2 = insertMissingPatchSet(ctl, rev2);
 
-    incrementPatchSet(c);
-    PatchSet ps3 = insertPatchSet(c);
+    ctl = incrementPatchSet(reload(ctl));
+    PatchSet ps3 = psUtil.current(db, ctl.getNotes());
 
-    incrementPatchSet(c);
-    PatchSet ps4 = insertMissingPatchSet(c,
-        "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee");
+    String rev4 = "c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee";
+    PatchSet ps4 = insertMissingPatchSet(ctl, rev4);
+    ctl = reload(ctl);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(4);
+    assertProblems(
+        ctl, fix,
+        problem("Ref missing: " + ps2.getId().toRefName()),
+        problem("Object missing: patch set 2: " + rev2,
+            FIXED, "Deleted patch set"),
+        problem("Ref missing: " + ps4.getId().toRefName()),
+        problem("Object missing: patch set 4: " + rev4,
+            FIXED, "Deleted patch set"));
 
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps4.getId().toRefName());
-    assertThat(p.status).isNull();
-
-    p = problems.get(1);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 4: c0ffeeeec0ffeeeec0ffeeeec0ffeeeec0ffeeee");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Deleted patch set");
-
-    p = problems.get(2);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps2.getId().toRefName());
-    assertThat(p.status).isNull();
-
-    p = problems.get(3);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 2: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Deleted patch set");
-
-    c = notesFactory.createChecked(db, c).getChange();
-    assertThat(c.currentPatchSetId().get()).isEqualTo(3);
-    assertThat(getPatchSet(ps1.getId())).isNotNull();
-    assertThat(getPatchSet(ps2.getId())).isNull();
-    assertThat(getPatchSet(ps3.getId())).isNotNull();
-    assertThat(getPatchSet(ps4.getId())).isNull();
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(3);
+    assertThat(psUtil.get(db, ctl.getNotes(), ps1.getId())).isNotNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps2.getId())).isNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps3.getId())).isNotNull();
+    assertThat(psUtil.get(db, ctl.getNotes(), ps4.getId())).isNull();
   }
 
   @Test
   public void onlyPatchSetObjectMissingWithFix() throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertMissingPatchSet(c,
-        "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+    Change c = TestChanges.newChange(
+        project, admin.getId(), sequences.nextChangeId());
+    PatchSet.Id psId = c.currentPatchSetId();
+    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    PatchSet ps = newPatchSet(psId, rev, adminId);
+
+    db.changes().insert(singleton(c));
+    db.patchSets().insert(singleton(ps));
+    addNoteDbCommit(
+        c.getId(),
+        "Create change\n"
+            + "\n"
+            + "Patch-set: 1\n"
+            + "Branch: " + c.getDest().get() + "\n"
+            + "Change-id: " + c.getKey().get() + "\n"
+            + "Subject: Bogus subject\n"
+            + "Commit: " + rev + "\n"
+            + "Groups: " + rev + "\n");
+    indexer.index(db, c.getProject(), c.getId());
+    IdentifiedUser user = userFactory.create(admin.getId());
+    ChangeControl ctl = changeControlFactory.controlFor(
+        db, c.getProject(), c.getId(), user);
 
     FixInput fix = new FixInput();
     fix.deletePatchSetIfCommitMissing = true;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(2);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo("Ref missing: " + ps1.getId().toRefName());
-    assertThat(p.status).isNull();
-    p = problems.get(1);
-    assertThat(p.message).isEqualTo(
-        "Object missing: patch set 1: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIX_FAILED);
-    assertThat(p.outcome)
-        .isEqualTo("Cannot delete patch set; no patch sets would remain");
+    assertProblems(
+        ctl, fix,
+        problem("Ref missing: " + ps.getId().toRefName()),
+        problem("Object missing: patch set 1: " + rev,
+            FIX_FAILED, "Cannot delete patch set; no patch sets would remain"));
 
-    c = notesFactory.createChecked(db, c).getChange();
-    assertThat(c.currentPatchSetId().get()).isEqualTo(1);
-    assertThat(getPatchSet(ps1.getId())).isNotNull();
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().currentPatchSetId().get()).isEqualTo(1);
+    assertThat(psUtil.current(db, ctl.getNotes())).isNotNull();
   }
 
   @Test
   public void currentPatchSetMissing() throws Exception {
-    Change c = insertChange();
-    assertProblems(c, "Current patch set 1 not found");
+    // NoteDb can't create a change without a patch set.
+    assume().that(notesMigration.enabled()).isFalse();
+
+    ChangeControl ctl = insertChange();
+    db.patchSets().deleteKeys(singleton(ctl.getChange().currentPatchSetId()));
+    assertProblems(ctl, null, problem("Current patch set 1 not found"));
   }
 
   @Test
   public void duplicatePatchSetRevisions() throws Exception {
-    Change c = insertChange();
-    PatchSet ps1 = insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
     String rev = ps1.getRevision().get();
-    incrementPatchSet(c);
-    PatchSet ps2 = insertMissingPatchSet(c, rev);
-    updatePatchSetRef(ps2);
 
-    assertProblems(c,
-        "Multiple patch sets pointing to " + rev + ": [1, 2]");
+    ctl = incrementPatchSet(
+        ctl, testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
+
+    assertProblems(
+        ctl, null,
+        problem("Multiple patch sets pointing to " + rev + ": [1, 2]"));
   }
 
   @Test
   public void missingDestRef() throws Exception {
+    ChangeControl ctl = insertChange();
+
     String ref = "refs/heads/master";
     // Detach head so we're allowed to delete ref.
     testRepo.reset(testRepo.getRepository().exactRef(ref).getObjectId());
     RefUpdate ru = testRepo.getRepository().updateRef(ref);
     ru.setForceUpdate(true);
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
-    Change c = insertChange();
-    RevCommit commit = testRepo.commit().create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps);
-    db.patchSets().insert(singleton(ps));
 
-    assertProblems(c, "Destination ref not found (may be new branch): " + ref);
+    assertProblems(
+        ctl, null,
+        problem("Destination ref not found (may be new branch): " + ref));
   }
 
   @Test
   public void mergedChangeIsNotMerged() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps = insertPatchSet(c);
-    String rev = ps.getRevision().get();
+    ChangeControl ctl = insertChange();
 
-    assertProblems(c,
-        "Patch set 1 (" + rev + ") is not merged into destination ref"
-        + " refs/heads/master (" + tip.name()
-        + "), but change status is MERGED");
+    try (BatchUpdate bu = newUpdate(adminId)) {
+      bu.addOp(ctl.getId(), new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          ctx.getChange().setStatus(Change.Status.MERGED);
+          ctx.getUpdate(ctx.getChange().currentPatchSetId())
+            .fixStatus(Change.Status.MERGED);
+          return true;
+        }
+      });
+      bu.execute();
+    }
+    ctl = reload(ctl);
+
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    ObjectId tip = getDestRef(ctl);
+    assertProblems(
+        ctl, null,
+        problem(
+            "Patch set 1 (" + rev + ") is not merged into destination ref"
+                + " refs/heads/master (" + tip.name()
+                + "), but change status is MERGED"));
   }
 
   @Test
   public void newChangeIsMerged() throws Exception {
-    Change c = insertChange();
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    db.patchSets().insert(singleton(ps));
-    testRepo.branch(c.getDest().get()).update(commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    assertProblems(c,
-        "Patch set 1 (" + commit.name() + ") is merged into destination ref"
-        + " refs/heads/master (" + commit.name()
-        + "), but change status is NEW");
+    assertProblems(
+        ctl, null,
+        problem(
+            "Patch set 1 (" + rev + ") is merged into destination ref"
+                + " refs/heads/master (" + rev
+                + "), but change status is NEW"));
   }
 
   @Test
   public void newChangeIsMergedWithFix() throws Exception {
-    Change c = insertChange();
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    db.patchSets().insert(singleton(ps));
-    testRepo.branch(c.getDest().get()).update(commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
-    List<ProblemInfo> problems = checker.check(c, new FixInput()).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Patch set 1 (" + commit.name() + ") is merged into destination ref"
-        + " refs/heads/master (" + commit.name()
-        + "), but change status is NEW");
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Marked change as merged");
+    assertProblems(
+        ctl, new FixInput(),
+        problem(
+            "Patch set 1 (" + rev + ") is merged into destination ref"
+                + " refs/heads/master (" + rev
+                + "), but change status is NEW",
+            FIXED, "Marked change as merged"));
 
-    c = notesFactory.createChecked(db, c).getChange();
-    assertThat(c.getStatus()).isEqualTo(Change.Status.MERGED);
-    assertProblems(c);
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void extensionApiReturnsUpdatedValueAfterFix() throws Exception {
-    Change c = insertChange();
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    db.patchSets().insert(singleton(ps));
-    testRepo.branch(c.getDest().get()).update(commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     ChangeInfo info = gApi.changes()
-        .id(c.getChangeId())
+        .id(ctl.getId().get())
         .info();
     assertThat(info.status).isEqualTo(ChangeStatus.NEW);
 
     info = gApi.changes()
-        .id(c.getChangeId())
+        .id(ctl.getId().get())
         .check(new FixInput());
     assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
   }
 
   @Test
   public void expectedMergedCommitIsLatestPatchSet() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
-    testRepo.update(c.getDest().get(), commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev)));
 
     FixInput fix = new FixInput();
-    fix.expectMergedAs = commit.name();
-    assertThat(checker.check(c, fix).problems()).isEmpty();
+    fix.expectMergedAs = rev;
+    assertProblems(
+        ctl, fix,
+        problem(
+            "Patch set 1 (" + rev + ") is merged into destination ref"
+                + " refs/heads/master (" + rev + "), but change status is NEW",
+            FIXED, "Marked change as merged"));
+
+    ctl = reload(ctl);
+    assertThat(ctl.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void expectedMergedCommitNotMergedIntoDestination() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
-    testRepo.update(c.getDest().get(), commit);
+    ChangeControl ctl = insertChange();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(ctl.getChange().getDest().get()).update(commit);
 
     FixInput fix = new FixInput();
     RevCommit other =
         testRepo.commit().message(commit.getFullMessage()).create();
     fix.expectMergedAs = other.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Expected merged commit " + other.name()
-        + " is not merged into destination ref refs/heads/master"
-        + " (" + commit.name() + ")");
+    assertProblems(
+        ctl, fix,
+        problem(
+            "Expected merged commit " + other.name()
+                + " is not merged into destination ref refs/heads/master"
+                + " (" + commit.name() + ")"));
   }
 
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithNoChangeId()
       throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    RevCommit parent =
-        testRepo.branch(c.getDest().get()).commit().message("parent").create();
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
+    ChangeControl ctl = insertChange();
+    String dest = ctl.getChange().getDest().get();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
-    RevCommit mergedAs = testRepo.commit().parent(parent)
+    RevCommit mergedAs = testRepo.commit().parent(commit.getParent(0))
         .message(commit.getShortMessage()).create();
     testRepo.getRevWalk().parseBody(mergedAs);
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID)).isEmpty();
-    testRepo.update(c.getDest().get(), mergedAs);
+    testRepo.update(dest, mergedAs);
 
-    assertProblems(c, "Patch set 1 (" + commit.name() + ") is not merged into"
-        + " destination ref refs/heads/master (" + mergedAs.name()
-        + "), but change status is MERGED");
+    assertNoProblems(ctl, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "No patch set found for merged commit " + mergedAs.name());
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Inserted as patch set 2");
+    assertProblems(
+        ctl, fix,
+        problem(
+            "No patch set found for merged commit " + mergedAs.name(),
+            FIXED, "Inserted as patch set 2"),
+        problem(
+            "Expected merged commit " + mergedAs.name()
+                + " has no associated patch set",
+            FIXED, "Marked change as merged"));
 
-    c = notesFactory.createChecked(db, c).getChange();
-    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), 2);
-    assertThat(c.currentPatchSetId()).isEqualTo(psId2);
-    assertThat(getPatchSet(psId2).getRevision().get())
+    ctl = reload(ctl);
+    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId2).getRevision().get())
         .isEqualTo(mergedAs.name());
 
-    assertProblems(c);
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void createNewPatchSetForExpectedMergeCommitWithChangeId()
       throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    RevCommit parent =
-        testRepo.branch(c.getDest().get()).commit().message("parent").create();
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
+    ChangeControl ctl = insertChange();
+    String dest = ctl.getChange().getDest().get();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
 
-    RevCommit mergedAs = testRepo.commit().parent(parent)
+    RevCommit mergedAs = testRepo.commit().parent(commit.getParent(0))
         .message(commit.getShortMessage() + "\n"
             + "\n"
-            + "Change-Id: " + c.getKey().get() + "\n").create();
+            + "Change-Id: " + ctl.getChange().getKey().get() + "\n").create();
     testRepo.getRevWalk().parseBody(mergedAs);
     assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
-        .containsExactly(c.getKey().get());
-    testRepo.update(c.getDest().get(), mergedAs);
+        .containsExactly(ctl.getChange().getKey().get());
+    testRepo.update(dest, mergedAs);
 
-    assertProblems(c, "Patch set 1 (" + commit.name() + ") is not merged into"
-        + " destination ref refs/heads/master (" + mergedAs.name()
-        + "), but change status is MERGED");
+    assertNoProblems(ctl, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "No patch set found for merged commit " + mergedAs.name());
-    assertThat(p.status).isEqualTo(ProblemInfo.Status.FIXED);
-    assertThat(p.outcome).isEqualTo("Inserted as patch set 2");
+    assertProblems(
+        ctl, fix,
+        problem(
+            "No patch set found for merged commit " + mergedAs.name(),
+            FIXED, "Inserted as patch set 2"),
+        problem(
+            "Expected merged commit " + mergedAs.name()
+                + " has no associated patch set",
+            FIXED, "Marked change as merged"));
 
-    c = notesFactory.createChecked(db, c).getChange();
-    PatchSet.Id psId2 = new PatchSet.Id(c.getId(), 2);
-    assertThat(c.currentPatchSetId()).isEqualTo(psId2);
-    assertThat(getPatchSet(psId2).getRevision().get())
+    ctl = reload(ctl);
+    PatchSet.Id psId2 = new PatchSet.Id(ctl.getId(), 2);
+    assertThat(ctl.getChange().currentPatchSetId()).isEqualTo(psId2);
+    assertThat(psUtil.get(db, ctl.getNotes(), psId2).getRevision().get())
         .isEqualTo(mergedAs.name());
 
-    assertProblems(c);
+    assertNoProblems(ctl, null);
   }
 
   @Test
   public void expectedMergedCommitIsOldPatchSetOfSameChange()
       throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
-    PatchSet ps1 = insertPatchSet(c);
+    ChangeControl ctl = insertChange();
+    PatchSet ps1 = psUtil.current(db, ctl.getNotes());
     String rev1 = ps1.getRevision().get();
-    incrementPatchSet(c);
-    PatchSet ps2 = insertPatchSet(c);
-    testRepo.branch(c.getDest().get()).update(parseCommit(ps1));
+    ctl = incrementPatchSet(ctl);
+    PatchSet ps2 = psUtil.current(db, ctl.getNotes());
+    testRepo.branch(ctl.getChange().getDest().get())
+        .update(testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev1)));
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = rev1;
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Expected merged commit " + rev1 + " corresponds to patch set "
-        + ps1.getId() + ", which is not the current patch set " + ps2.getId());
+    assertProblems(
+        ctl, fix,
+        problem(
+            "Expected merged commit " + rev1 + " corresponds to patch set "
+                + ps1.getId() + ", which is not the current patch set "
+                + ps2.getId()));
   }
 
   @Test
   public void expectedMergedCommitWithMismatchedChangeId() throws Exception {
-    Change c = insertChange();
-    c.setStatus(Change.Status.MERGED);
+    ChangeControl ctl = insertChange();
+    String dest = ctl.getChange().getDest().get();
     RevCommit parent =
-        testRepo.branch(c.getDest().get()).commit().message("parent").create();
-    PatchSet ps = insertPatchSet(c);
-    RevCommit commit = parseCommit(ps);
+        testRepo.branch(dest).commit().message("parent").create();
+    String rev = psUtil.current(db, ctl.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(dest).update(commit);
 
     String badId = "I0000000000000000000000000000000000000000";
     RevCommit mergedAs = testRepo.commit().parent(parent)
@@ -567,85 +607,140 @@
             + "Change-Id: " + badId + "\n")
         .create();
     testRepo.getRevWalk().parseBody(mergedAs);
-    testRepo.update(c.getDest().get(), mergedAs);
+    assertThat(mergedAs.getFooterLines(FooterConstants.CHANGE_ID))
+        .containsExactly(badId);
+    testRepo.update(dest, mergedAs);
+
+    assertNoProblems(ctl, null);
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = mergedAs.name();
-    List<ProblemInfo> problems = checker.check(c, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Expected merged commit " + mergedAs.name() + " has Change-Id: "
-        + badId + ", but expected " + c.getKey().get());
+    assertProblems(
+        ctl, fix,
+        problem(
+            "Expected merged commit " + mergedAs.name() + " has Change-Id: "
+                + badId + ", but expected " + ctl.getChange().getKey().get()));
   }
 
   @Test
   public void expectedMergedCommitMatchesMultiplePatchSets()
       throws Exception {
-    Change c1 = insertChange();
-    c1.setStatus(Change.Status.MERGED);
-    insertPatchSet(c1);
+    ChangeControl ctl1 = insertChange();
+    PatchSet.Id psId1 = psUtil.current(db, ctl1.getNotes()).getId();
+    String dest = ctl1.getChange().getDest().get();
+    String rev = psUtil.current(db, ctl1.getNotes()).getRevision().get();
+    RevCommit commit =
+        testRepo.getRevWalk().parseCommit(ObjectId.fromString(rev));
+    testRepo.branch(dest).update(commit);
 
-    RevCommit commit = testRepo.branch(c1.getDest().get()).commit().create();
-    Change c2 = insertChange();
-    PatchSet ps2 = newPatchSet(c2.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps2);
-    db.patchSets().insert(singleton(ps2));
+    ChangeControl ctl2 = insertChange();
+    ctl2 = incrementPatchSet(ctl2, commit);
+    PatchSet.Id psId2 = psUtil.current(db, ctl2.getNotes()).getId();
 
-    Change c3 = insertChange();
-    PatchSet ps3 = newPatchSet(c3.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps3);
-    db.patchSets().insert(singleton(ps3));
+    ChangeControl ctl3 = insertChange();
+    ctl3 = incrementPatchSet(ctl3, commit);
+    PatchSet.Id psId3 = psUtil.current(db, ctl3.getNotes()).getId();
 
     FixInput fix = new FixInput();
     fix.expectMergedAs = commit.name();
-    List<ProblemInfo> problems = checker.check(c1, fix).problems();
-    assertThat(problems).hasSize(1);
-    ProblemInfo p = problems.get(0);
-    assertThat(p.message).isEqualTo(
-        "Multiple patch sets for expected merged commit " + commit.name()
-        + ": [" + ps2.getId() + ", " + ps3.getId() + "]");
+    assertProblems(
+        ctl1, fix,
+        problem(
+            "Multiple patch sets for expected merged commit " + commit.name()
+                + ": [" + psId1 + ", " + psId2 + ", " + psId3 + "]"));
   }
 
-  private Change insertChange() throws Exception {
-    Change c = newChange(project, adminId);
-    db.changes().insert(singleton(c));
-    indexer.index(db, c);
-
-    ChangeUpdate u = changeUpdateFactory.create(
-        changeControlFactory.controlFor(db, c, userFactory.create(adminId)));
-    u.setBranch(c.getDest().get());
-    u.setChangeId(c.getKey().get());
-    u.commit();
-
-    return c;
+  private BatchUpdate newUpdate(Account.Id owner) {
+    return updateFactory.create(
+        db, project, userFactory.create(owner), TimeUtil.nowTs());
   }
 
-  private void incrementPatchSet(Change c) throws Exception {
-    TestChanges.incrementPatchSet(c);
-    db.changes().upsert(singleton(c));
+  private ChangeControl insertChange() throws Exception {
+    return insertChange(admin);
   }
 
-  private PatchSet insertPatchSet(Change c) throws Exception {
-    db.changes().upsert(singleton(c));
-    RevCommit commit = testRepo.branch(c.currentPatchSetId().toRefName()).commit()
-        .parent(tip).message("Change " + c.getId().get()).create();
-    PatchSet ps = newPatchSet(c.currentPatchSetId(), commit, adminId);
-    updatePatchSetRef(ps);
+
+  private ChangeControl insertChange(TestAccount owner) throws Exception {
+    return insertChange(owner, "refs/heads/master");
+  }
+
+  private ChangeControl insertChange(TestAccount owner, String dest)
+      throws Exception {
+    Change.Id id = new Change.Id(sequences.nextChangeId());
+    ChangeInserter ins;
+    try (BatchUpdate bu = newUpdate(owner.getId())) {
+      RevCommit commit = patchSetCommit(new PatchSet.Id(id, 1));
+      ins = changeInserterFactory
+          .create(id, commit, dest)
+          .setValidatePolicy(CommitValidators.Policy.NONE)
+          .setNotify(NotifyHandling.NONE)
+          .setFireRevisionCreated(false)
+          .setSendMail(false);
+      bu.insertChange(ins).execute();
+    }
+    // Return control for admin regardless of owner.
+    return changeControlFactory.controlFor(
+        db, ins.getChange(), userFactory.create(adminId));
+  }
+
+  private PatchSet.Id nextPatchSetId(ChangeControl ctl) {
+    return ChangeUtil.nextPatchSetId(ctl.getChange().currentPatchSetId());
+  }
+
+  private ChangeControl incrementPatchSet(ChangeControl ctl) throws Exception {
+    return incrementPatchSet(ctl, patchSetCommit(nextPatchSetId(ctl)));
+  }
+
+  private ChangeControl incrementPatchSet(ChangeControl ctl,
+      RevCommit commit) throws Exception {
+    PatchSetInserter ins;
+    try (BatchUpdate bu = newUpdate(ctl.getChange().getOwner())) {
+      ins = patchSetInserterFactory.create(ctl, nextPatchSetId(ctl), commit)
+          .setValidatePolicy(CommitValidators.Policy.NONE)
+          .setFireRevisionCreated(false)
+          .setSendMail(false);
+      bu.addOp(ctl.getId(), ins).execute();
+    }
+    return reload(ctl);
+  }
+
+  private ChangeControl reload(ChangeControl ctl) throws Exception {
+    return changeControlFactory.controlFor(
+        db, ctl.getChange().getProject(), ctl.getId(), ctl.getUser());
+  }
+
+  private RevCommit patchSetCommit(PatchSet.Id psId) throws Exception {
+    RevCommit c = testRepo
+        .commit()
+        .parent(tip)
+        .message("Change " + psId)
+        .create();
+    return testRepo.parseBody(c);
+  }
+
+  private PatchSet insertMissingPatchSet(ChangeControl ctl, String rev)
+      throws Exception {
+    // Don't use BatchUpdate since we're manually updating the meta ref rather
+    // than using ChangeUpdate.
+    String subject = "Subject for missing commit";
+    Change c = new Change(ctl.getChange());
+    PatchSet.Id psId = nextPatchSetId(ctl);
+    c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
+
+    PatchSet ps = newPatchSet(psId, rev, adminId);
     db.patchSets().insert(singleton(ps));
-    return ps;
-  }
+    db.changes().update(singleton(c));
 
-  private PatchSet insertMissingPatchSet(Change c, String id) throws Exception {
-    PatchSet ps = newPatchSet(c.currentPatchSetId(),
-        ObjectId.fromString(id), adminId);
-    db.patchSets().insert(singleton(ps));
-    return ps;
-  }
+    addNoteDbCommit(
+        c.getId(),
+        "Update patch set " + psId.get() + "\n"
+            + "\n"
+            + "Patch-set: " + psId.get() + "\n"
+            + "Commit: " + rev + "\n"
+            + "Subject: " + subject + "\n");
+    indexer.index(db, c.getProject(), c.getId());
 
-  private void updatePatchSetRef(PatchSet ps) throws Exception {
-    testRepo.update(ps.getId().toRefName(),
-        ObjectId.fromString(ps.getRevision().get()));
+    return ps;
   }
 
   private void deleteRef(String refName) throws Exception {
@@ -654,22 +749,82 @@
     assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
   }
 
-  private RevCommit parseCommit(PatchSet ps) throws Exception {
-    RevCommit commit = testRepo.getRevWalk()
-        .parseCommit(ObjectId.fromString(ps.getRevision().get()));
-    testRepo.getRevWalk().parseBody(commit);
-    return commit;
+  private void addNoteDbCommit(Change.Id id, String commitMessage)
+      throws Exception {
+    if (!notesMigration.commitChangeWrites()) {
+      return;
+    }
+    PersonIdent committer = serverIdent.get();
+    PersonIdent author = noteUtil.newIdent(
+        accountCache.get(admin.getId()).getAccount(),
+        committer.getWhen(),
+        committer,
+        anonymousCowardName);
+    testRepo.branch(RefNames.changeMetaRef(id))
+        .commit()
+        .author(author)
+        .committer(committer)
+        .message(commitMessage)
+        .create();
   }
 
-  private void assertProblems(Change c, String... expected) {
-    assertThat(Lists.transform(checker.check(c).problems(),
-          new Function<ProblemInfo, String>() {
-            @Override
-            public String apply(ProblemInfo in) {
-              checkArgument(in.status == null,
-                  "Status is not null: " + in.message);
-              return in.message;
-            }
-          })).containsExactly((Object[]) expected);
+  private ObjectId getDestRef(ChangeControl ctl) throws Exception {
+    return testRepo.getRepository()
+        .exactRef(ctl.getChange().getDest().get())
+        .getObjectId();
+  }
+
+  private ChangeControl mergeChange(ChangeControl ctl) throws Exception {
+    final ObjectId oldId = getDestRef(ctl);
+    final ObjectId newId = ObjectId.fromString(
+        psUtil.current(db, ctl.getNotes()).getRevision().get());
+    final String dest = ctl.getChange().getDest().get();
+
+    try (BatchUpdate bu = newUpdate(adminId)) {
+      bu.addOp(ctl.getId(), new BatchUpdate.Op() {
+        @Override
+        public void updateRepo(RepoContext ctx) throws IOException {
+          ctx.addRefUpdate(new ReceiveCommand(oldId, newId, dest));
+        }
+
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          ctx.getChange().setStatus(Change.Status.MERGED);
+          ctx.getUpdate(ctx.getChange().currentPatchSetId())
+            .fixStatus(Change.Status.MERGED);
+          return true;
+        }
+      });
+      bu.execute();
+    }
+    return reload(ctl);
+  }
+
+  private static ProblemInfo problem(String message) {
+    ProblemInfo p = new ProblemInfo();
+    p.message = message;
+    return p;
+  }
+
+  private static ProblemInfo problem(String message,
+      ProblemInfo.Status status, String outcome) {
+    ProblemInfo p = problem(message);
+    p.status = checkNotNull(status);
+    p.outcome = checkNotNull(outcome);
+    return p;
+  }
+
+  private void assertProblems(ChangeControl ctl, @Nullable FixInput fix,
+      ProblemInfo first, ProblemInfo... rest) {
+    List<ProblemInfo> expected = new ArrayList<>(1 + rest.length);
+    expected.add(first);
+    expected.addAll(Arrays.asList(rest));
+    assertThat(checker.check(ctl, fix).problems())
+        .containsExactlyElementsIn(expected)
+        .inOrder();
+  }
+
+  private void assertNoProblems(ChangeControl ctl, @Nullable FixInput fix) {
+    assertThat(checker.check(ctl, fix).problems()).isEmpty();
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
index 30a23bf..ca61b1d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
@@ -14,15 +14,22 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
 public class AddReviewerInput {
   @DefaultInput
   public String reviewer;
   public Boolean confirmed;
+  public ReviewerState state;
 
   public boolean confirmed() {
     return (confirmed != null) ? confirmed : false;
   }
-}
 
+  public ReviewerState state() {
+    return (state != null) ? state : REVIEWER;
+  }
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
index c29a635..10f74ff84 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.api.changes;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.AccountInfo;
 
 import java.util.List;
 
@@ -49,6 +50,14 @@
   public List<ReviewerInfo> reviewers;
 
   /**
+   * List of accounts CCed on the change. The size of this list may be
+   * greater than one (e.g. when a group is CCed). Null if no accounts were CCed
+   * or if reviewers is non-null.
+   */
+  @Nullable
+  public List<AccountInfo> ccs;
+
+  /**
    * Constructs a partially initialized result for the given reviewer.
    *
    * @param input String identifier of an account or group, from user request
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
index 8b626b7..9d94f50 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/DraftInput.java
@@ -16,6 +16,22 @@
 
 import com.google.gerrit.extensions.client.Comment;
 
+import java.util.Objects;
+
 public class DraftInput extends Comment {
   public String tag;
+
+  @Override
+  public boolean equals(Object o) {
+    if (super.equals(o)) {
+      DraftInput di = (DraftInput) o;
+      return Objects.equals(tag, di.tag);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), tag);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
index 3641ac5..2536c46 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -35,6 +35,11 @@
   DiffInfo diff(String base) throws RestApiException;
 
   /**
+   * @param parent 1-based parent number to diff against
+   */
+  DiffInfo diff(int parent) throws RestApiException;
+
+  /**
    * Creates a request to retrieve the diff. On the returned request formatting
    * options for the diff can be set.
    */
@@ -106,6 +111,11 @@
     }
 
     @Override
+    public DiffInfo diff(int parent) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public DiffRequest diffRequest() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 9749e18..cbe16ed 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -14,9 +14,13 @@
 
 package com.google.gerrit.extensions.api.changes;
 
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
 import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 
+import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -68,6 +72,11 @@
    */
   public String onBehalfOf;
 
+  /**
+   * Reviewers that should be added to this change.
+   */
+  public List<AddReviewerInput> reviewers;
+
   public enum DraftHandling {
     /** Delete pending drafts on this revision only. */
     DELETE,
@@ -112,6 +121,23 @@
     return label(name, (short) 1);
   }
 
+  public ReviewInput reviewer(String reviewer) {
+    return reviewer(reviewer, REVIEWER, false);
+  }
+
+  public ReviewInput reviewer(String reviewer, ReviewerState state,
+      boolean confirmed) {
+    AddReviewerInput input = new AddReviewerInput();
+    input.reviewer = reviewer;
+    input.state = state;
+    input.confirmed = confirmed;
+    if (reviewers == null) {
+      reviewers = new ArrayList<>();
+    }
+    reviewers.add(input);
+    return this;
+  }
+
   public static ReviewInput recommend() {
     return new ReviewInput().label("Code-Review", 1);
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
new file mode 100644
index 0000000..b9de2e1
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -0,0 +1,38 @@
+// 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.extensions.api.changes;
+
+import com.google.gerrit.common.Nullable;
+
+import java.util.Map;
+
+/**
+ * Result object representing the outcome of a review request.
+ */
+public class ReviewResult {
+  /**
+   * Map of labels to values after the review was posted. Null if any
+   * reviewer additions were rejected.
+   */
+  @Nullable
+  public Map<String, Short> labels;
+
+  /**
+   * Map of account or group identifier to outcome of adding as a reviewer.
+   * Null if no reviewer additions were requested.
+   */
+  @Nullable
+  public Map<String, AddReviewerResult> reviewers;
+}
\ No newline at end of file
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index b23c7f9..2731476 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -46,6 +46,7 @@
 
   Map<String, FileInfo> files() throws RestApiException;
   Map<String, FileInfo> files(String base) throws RestApiException;
+  Map<String, FileInfo> files(int parentNum) throws RestApiException;
   FileApi file(String path);
   MergeableInfo mergeable() throws RestApiException;
   MergeableInfo mergeableOtherBranches() throws RestApiException;
@@ -147,6 +148,11 @@
     }
 
     @Override
+    public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, FileInfo> files() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
index b9863d7..7c8a3e8 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.extensions.client;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 public abstract class Comment {
   /**
@@ -27,6 +28,7 @@
   public String id;
   public String path;
   public Side side;
+  public Integer parent;
   public Integer line;
   public Range range;
   public String inReplyTo;
@@ -38,5 +40,49 @@
     public int startCharacter;
     public int endLine;
     public int endCharacter;
+
+    @Override
+    public boolean equals(Object o) {
+      if (o instanceof Range) {
+        Range r = (Range) o;
+        return Objects.equals(startLine, r.startLine)
+            && Objects.equals(startCharacter, r.startCharacter)
+            && Objects.equals(endLine, r.endLine)
+            && Objects.equals(endCharacter, r.endCharacter);
+      }
+      return false;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(startLine, startCharacter, endLine, endCharacter);
+    }
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (o != null && getClass() == o.getClass()) {
+      Comment c = (Comment) o;
+      return Objects.equals(patchSet, c.patchSet)
+          && Objects.equals(id, c.id)
+          && Objects.equals(path, c.path)
+          && Objects.equals(side, c.side)
+          && Objects.equals(parent, c.parent)
+          && Objects.equals(line, c.line)
+          && Objects.equals(range, c.range)
+          && Objects.equals(inReplyTo, c.inReplyTo)
+          && Objects.equals(updated, c.updated)
+          && Objects.equals(message, c.message);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(patchSet, id, path, side, parent, line, range,
+        inReplyTo, updated, message);
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java
index 3485b8b..e077df2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/Side.java
@@ -19,11 +19,10 @@
   REVISION;
 
   public static Side fromShort(short s) {
-    switch (s) {
-      case 0:
-        return PARENT;
-      case 1:
-        return REVISION;
+    if (s <= 0) {
+      return PARENT;
+    } else if (s == 1) {
+      return REVISION;
     }
     return null;
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
index b7535e1..166aaa2 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -16,7 +16,24 @@
 
 import com.google.gerrit.extensions.client.Comment;
 
+import java.util.Objects;
+
 public class CommentInfo extends Comment {
   public AccountInfo author;
   public String tag;
+
+  @Override
+  public boolean equals(Object o) {
+    if (super.equals(o)) {
+      CommentInfo ci = (CommentInfo) o;
+      return Objects.equals(author, ci.author)
+          && Objects.equals(tag, ci.tag);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(super.hashCode(), author, tag);
+  }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
index 4dd910d..ff04fdc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/ProblemInfo.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Objects;
+
 public class ProblemInfo {
   public enum Status {
     FIXED, FIX_FAILED
@@ -24,6 +26,22 @@
   public String outcome;
 
   @Override
+  public int hashCode() {
+    return Objects.hash(message, status, outcome);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (!(o instanceof ProblemInfo)) {
+      return false;
+    }
+    ProblemInfo p = (ProblemInfo) o;
+    return Objects.equals(message, p.message)
+        && Objects.equals(status, p.status)
+        && Objects.equals(outcome, p.outcome);
+  }
+
+  @Override
   public String toString() {
     StringBuilder sb = new StringBuilder(getClass().getSimpleName())
         .append('[').append(message);
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index c8694d4..cd172a7 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -39,6 +39,7 @@
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
 import com.google.gerrit.gpg.server.PostGpgKeys.Input;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
@@ -46,7 +47,10 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResource;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.mail.AddKeySender;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -87,6 +91,8 @@
   private final GerritPublicKeyChecker.Factory checkerFactory;
   private final AddKeySender.Factory addKeyFactory;
   private final AccountCache accountCache;
+  private final AccountIndexCollection accountIndexes;
+  private final Provider<InternalAccountQuery> accountQueryProvider;
 
   @Inject
   PostGpgKeys(@GerritPersonIdent Provider<PersonIdent> serverIdent,
@@ -95,7 +101,9 @@
       Provider<PublicKeyStore> storeProvider,
       GerritPublicKeyChecker.Factory checkerFactory,
       AddKeySender.Factory addKeyFactory,
-      AccountCache accountCache) {
+      AccountCache accountCache,
+      AccountIndexCollection accountIndexes,
+      Provider<InternalAccountQuery> accountQueryProvider) {
     this.serverIdent = serverIdent;
     this.db = db;
     this.self = self;
@@ -103,6 +111,8 @@
     this.checkerFactory = checkerFactory;
     this.addKeyFactory = addKeyFactory;
     this.accountCache = accountCache;
+    this.accountIndexes = accountIndexes;
+    this.accountQueryProvider = accountQueryProvider;
   }
 
   @Override
@@ -122,15 +132,28 @@
       for (PGPPublicKeyRing keyRing : newKeys) {
         PGPPublicKey key = keyRing.getPublicKey();
         AccountExternalId.Key extIdKey = toExtIdKey(key.getFingerprint());
-        AccountExternalId existing = db.get().accountExternalIds().get(extIdKey);
-        if (existing != null) {
-          if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) {
-            throw new ResourceConflictException(
-                "GPG key already associated with another account");
+        if (accountIndexes.getSearchIndex() != null) {
+          Account account = getAccountByExternalId(extIdKey.get());
+          if (account != null) {
+            if (!account.getId().equals(rsrc.getUser().getAccountId())) {
+              throw new ResourceConflictException(
+                  "GPG key already associated with another account");
+            }
+          } else {
+            newExtIds.add(
+                new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
           }
         } else {
-          newExtIds.add(
-              new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
+          AccountExternalId existing = db.get().accountExternalIds().get(extIdKey);
+          if (existing != null) {
+            if (!existing.getAccountId().equals(rsrc.getUser().getAccountId())) {
+              throw new ResourceConflictException(
+                  "GPG key already associated with another account");
+            }
+          } else {
+            newExtIds.add(
+                new AccountExternalId(rsrc.getUser().getAccountId(), extIdKey));
+          }
         }
       }
 
@@ -257,6 +280,33 @@
         BaseEncoding.base16().encode(fp));
   }
 
+  private Account getAccountByExternalId(String externalId)
+      throws OrmException {
+    List<AccountState> accountStates =
+        accountQueryProvider.get().byExternalId(externalId);
+
+    if (accountStates.isEmpty()) {
+      return null;
+    }
+
+    if (accountStates.size() > 1) {
+      StringBuilder msg = new StringBuilder();
+      msg.append("GPG key ").append(externalId)
+          .append(" associated with multiple accounts: ");
+      Joiner.on(", ").appendTo(msg,
+          Lists.transform(accountStates, new Function<AccountState, String>() {
+            @Override
+            public String apply(AccountState accountState) {
+              return accountState.getAccount().getId().toString();
+            }
+          }));
+      log.error(msg.toString());
+      throw new IllegalStateException(msg.toString());
+    }
+
+    return accountStates.get(0).getAccount();
+  }
+
   private Map<String, GpgKeyInfo> toJson(
       Collection<PGPPublicKeyRing> keys,
       Set<Fingerprint> deleted, PublicKeyStore store, IdentifiedUser user)
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
index 053b8c5..9eea93e 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/info/ChangeInfo.java
@@ -333,12 +333,22 @@
       revisionInfo.takeFromEdit(edit);
       return revisionInfo;
     }
+    public static RevisionInfo forParent(int number, CommitInfo commit) {
+      RevisionInfo revisionInfo = createObject().cast();
+      revisionInfo.takeFromParent(number, commit);
+      return revisionInfo;
+    }
     private native void takeFromEdit(EditInfo edit) /*-{
       this._number = 0;
       this.name = edit.name;
       this.commit = edit.commit;
       this.edit_base = edit.base_revision;
     }-*/;
+    private native void takeFromParent(int number, CommitInfo commit) /*-{
+      this._number = number;
+      this.commit = commit;
+      this.name = this._number;
+    }-*/;
     public final native int _number() /*-{ return this._number; }-*/;
     public final native String name() /*-{ return this.name; }-*/;
     public final native boolean draft() /*-{ return this.draft || false; }-*/;
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
index 340460f..8da949c 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen.java
@@ -981,24 +981,25 @@
       final List<NativeMap<JsArray<CommentInfo>>> comments,
       final List<NativeMap<JsArray<CommentInfo>>> drafts) {
     DiffApi.list(changeId.get(),
-      base != null ? base.name() : null,
-      rev.name(),
-      group.add(new AsyncCallback<NativeMap<FileInfo>>() {
-        @Override
-        public void onSuccess(NativeMap<FileInfo> m) {
-          files.set(
-              base != null ? new PatchSet.Id(changeId, base._number()) : null,
-              new PatchSet.Id(changeId, rev._number()),
-              style, reply, fileTableMode, edit != null);
-          files.setValue(m, myLastReply,
-              comments != null ? comments.get(0) : null,
-              drafts != null ? drafts.get(0) : null);
-        }
+        rev.name(),
+        base,
+        group.add(
+            new AsyncCallback<NativeMap<FileInfo>>() {
+              @Override
+              public void onSuccess(NativeMap<FileInfo> m) {
+                files.set(
+                    base != null ? new PatchSet.Id(changeId, base._number()) : null,
+                    new PatchSet.Id(changeId, rev._number()),
+                    style, reply, fileTableMode, edit != null);
+                files.setValue(m, myLastReply,
+                    comments != null ? comments.get(0) : null,
+                    drafts != null ? drafts.get(0) : null);
+              }
 
-        @Override
-        public void onFailure(Throwable caught) {
-        }
-      }));
+              @Override
+              public void onFailure(Throwable caught) {
+              }
+            }));
   }
 
   private List<NativeMap<JsArray<CommentInfo>>> loadComments(
@@ -1117,7 +1118,6 @@
   }
 
   /**
-   *
    * Resolve a revision or patch set id string to RevisionInfo.
    * When this view is created from the changes table, revision
    * is passed as a real revision.
@@ -1131,8 +1131,17 @@
    */
   private RevisionInfo resolveRevisionOrPatchSetId(ChangeInfo info,
       String revOrId, String defaultValue) {
+    int parentNum;
     if (revOrId == null) {
       revOrId = defaultValue;
+    } else if ((parentNum = toParentNum(revOrId)) > 0) {
+      CommitInfo commitInfo = info.revision(revision).commit();
+      if (commitInfo != null) {
+        JsArray<CommitInfo> parents = commitInfo.parents();
+        if (parents.length() >= parentNum) {
+          return RevisionInfo.forParent(-parentNum, parents.get(parentNum - 1));
+        }
+      }
     } else if (!info.revisions().containsKey(revOrId)) {
       JsArray<RevisionInfo> list = info.revisions().values();
       for (int i = 0; i < list.length(); i++) {
@@ -1389,9 +1398,20 @@
 
     RevisionInfo rev = info.revisions().get(revision);
     JsArray<CommitInfo> parents = rev.commit().parents();
-    diffBase.addItem(
-      parents.length() > 1 ? Util.C.autoMerge() : Util.C.baseDiffItem(),
-      "");
+    if (parents.length() > 1) {
+      diffBase.addItem(Util.C.autoMerge(), "");
+      for (int i = 0; i < parents.length(); i++) {
+        int parentNum = i + 1;
+        diffBase.addItem(Util.M.diffBaseParent(parentNum),
+            String.valueOf(-parentNum));
+      }
+      int parentNum = toParentNum(base);
+      if (parentNum > 0) {
+        selectedIdx = list.length() + parentNum;
+      }
+    } else {
+      diffBase.addItem(Util.C.baseDiffItem(), "");
+    }
 
     diffBase.setSelectedIndex(selectedIdx);
   }
@@ -1443,4 +1463,22 @@
   private static String normalize(String r) {
     return r != null && !r.isEmpty() ? r : null;
   }
+
+  /**
+   * @param parentToken
+   * @return 1-based parentNum if parentToken is a String which can be parsed as
+   *     a negative integer i.e. "-1", "-2", etc. If parentToken cannot be
+   *     parsed as a negative integer, return zero.
+   */
+  private static int toParentNum(String parentToken) {
+    try {
+      int n = Integer.parseInt(parentToken);
+      if (n < 0) {
+        return -n;
+      }
+      return 0;
+    } catch (NumberFormatException e) {
+      return 0;
+    }
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
index 60d66e0..f0a7ce3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/FileTable.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.client.ui.NavigationTable;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -712,8 +713,8 @@
     }
 
     private void columnComments(SafeHtmlBuilder sb, FileInfo info) {
-      JsArray<CommentInfo> cList = get(info.path(), comments);
-      JsArray<CommentInfo> dList = get(info.path(), drafts);
+      JsArray<CommentInfo> cList = filterForParent(get(info.path(), comments));
+      JsArray<CommentInfo> dList = filterForParent(get(info.path(), drafts));
 
       sb.openTd().setStyleName(R.css().draftColumn());
       if (dList.length() > 0) {
@@ -747,6 +748,20 @@
       sb.closeTd();
     }
 
+    private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
+      JsArray<CommentInfo> result = JsArray.createArray().cast();
+      for (CommentInfo c : Natives.asList(list)) {
+        if (c.side() == Side.REVISION) {
+          result.push(c);
+        } else if (base == null && !c.hasParent()) {
+          result.push(c);
+        } else if (base != null && c.parent() == -base.get()) {
+          result.push(c);
+        }
+      }
+      return result;
+    }
+
     private JsArray<CommentInfo> get(String p, NativeMap<JsArray<CommentInfo>> m) {
       JsArray<CommentInfo> r = null;
       if (m != null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
index c5397ee..b192bd5 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.java
@@ -42,4 +42,6 @@
   String changeQueryPageTitle(String query);
 
   String insertionsAndDeletions(int insertions, int deletions);
+
+  String diffBaseParent(int parentNum);
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
index f0d7e59..2b68492 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
@@ -23,3 +23,5 @@
 changeQueryPageTitle = Search for {0}
 
 insertionsAndDeletions = +{0}, -{1}
+
+diffBaseParent = Parent {0}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
index 8e73f73..d42c344 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/CommentInfo.java
@@ -25,9 +25,15 @@
 public class CommentInfo extends JavaScriptObject {
   public static CommentInfo create(String path, Side side,
       int line, CommentRange range) {
+    return create(path, side, 0, line, range);
+  }
+
+  public static CommentInfo create(String path, Side side, int parent,
+      int line, CommentRange range) {
     CommentInfo n = createObject().cast();
     n.path(path);
     n.side(side);
+    n.parent(parent);
     if (range != null) {
       n.line(range.endLine());
       n.range(range);
@@ -41,6 +47,7 @@
     CommentInfo n = createObject().cast();
     n.path(r.path());
     n.side(r.side());
+    n.parent(r.parent());
     n.inReplyTo(r.id());
     if (r.hasRange()) {
       n.line(r.range().endLine());
@@ -55,6 +62,7 @@
     CommentInfo n = createObject().cast();
     n.path(s.path());
     n.side(s.side());
+    n.parent(s.parent());
     n.id(s.id());
     n.inReplyTo(s.inReplyTo());
     n.message(s.message());
@@ -78,6 +86,8 @@
     sideRaw(side.toString());
   }
   private native void sideRaw(String s) /*-{ this.side = s }-*/;
+  public final native void parent(int n) /*-{ this.parent = n }-*/;
+  public final native boolean hasParent() /*-{ return this.hasOwnProperty('parent') }-*/;
 
   public final native String path() /*-{ return this.path }-*/;
   public final native String id() /*-{ return this.id }-*/;
@@ -91,6 +101,7 @@
         : Side.REVISION;
   }
   private native String sideRaw() /*-{ return this.side }-*/;
+  public final native int parent() /*-{ return this.parent }-*/;
 
   public final Timestamp updated() {
     Timestamp r = updatedTimestamp();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
index a26b1ce..2f3ead3 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentManager.java
@@ -129,16 +129,29 @@
   }
 
   Side getStoredSideFromDisplaySide(DisplaySide side) {
-    return side == DisplaySide.A && base == null ? Side.PARENT : Side.REVISION;
+    if (side == DisplaySide.A && (base == null || base.get() < 0)) {
+      return Side.PARENT;
+    }
+    return Side.REVISION;
+  }
+
+  int getParentNumFromDisplaySide(DisplaySide side) {
+    if (side == DisplaySide.A && base != null && base.get() < 0) {
+      return -base.get();
+    }
+    return 0;
   }
 
   PatchSet.Id getPatchSetIdFromSide(DisplaySide side) {
-    return side == DisplaySide.A && base != null ? base : revision;
+    if (side == DisplaySide.A && base != null && base.get() >= 0) {
+      return base;
+    }
+    return revision;
   }
 
   DisplaySide displaySide(CommentInfo info, DisplaySide forSide) {
     if (info.side() == Side.PARENT) {
-      return base == null ? DisplaySide.A : null;
+      return (base == null || base.get() < 0) ? DisplaySide.A : null;
     }
     return forSide;
   }
@@ -179,6 +192,7 @@
       addDraftBox(side, CommentInfo.create(
           getPath(),
           getStoredSideFromDisplaySide(side),
+          getParentNumFromDisplaySide(side),
           line,
           null)).setEdit(true);
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
index 83f74a3..ce1d294 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentsCollections.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.client.rpc.CallbackGroup;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.Natives;
+import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.core.client.JsArray;
 import com.google.gwt.user.client.rpc.AsyncCallback;
@@ -46,13 +47,13 @@
   }
 
   void load(CallbackGroup group) {
-    if (base != null) {
+    if (base != null && base.get() > 0) {
       CommentApi.comments(base, group.add(publishedBase()));
     }
     CommentApi.comments(revision, group.add(publishedRevision()));
 
     if (Gerrit.isSignedIn()) {
-      if (base != null) {
+      if (base != null && base.get() > 0) {
         CommentApi.drafts(base, group.add(draftsBase()));
       }
       CommentApi.drafts(revision, group.add(draftsRevision()));
@@ -60,7 +61,7 @@
   }
 
   boolean hasCommentForPath(String filePath) {
-    if (base != null) {
+    if (base != null && base.get() > 0) {
       JsArray<CommentInfo> forBase = publishedBaseAll.get(filePath);
       if (forBase != null && forBase.length() > 0) {
         return true;
@@ -91,6 +92,9 @@
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
       @Override
       public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+        for (String k : result.keySet()) {
+          result.put(k, filterForParent(result.get(k)));
+        }
         publishedRevisionAll = result;
         publishedRevision = sort(result.get(path));
       }
@@ -101,6 +105,20 @@
     };
   }
 
+    private JsArray<CommentInfo> filterForParent(JsArray<CommentInfo> list) {
+      JsArray<CommentInfo> result = JsArray.createArray().cast();
+      for (CommentInfo c : Natives.asList(list)) {
+        if (c.side() == Side.REVISION) {
+          result.push(c);
+        } else if (base == null && !c.hasParent()) {
+          result.push(c);
+        } else if (base != null && c.parent() == -base.get()) {
+          result.push(c);
+        }
+      }
+      return result;
+    }
+
   private AsyncCallback<NativeMap<JsArray<CommentInfo>>> draftsBase() {
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
       @Override
@@ -118,6 +136,9 @@
     return new AsyncCallback<NativeMap<JsArray<CommentInfo>>>() {
       @Override
       public void onSuccess(NativeMap<JsArray<CommentInfo>> result) {
+        for (String k : result.keySet()) {
+          result.put(k, filterForParent(result.get(k)));
+        }
         draftsRevision = sort(result.get(path));
       }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
index bc5a305..e3720cc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffApi.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace.IGNORE_ALL;
 
 import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.info.FileInfo;
 import com.google.gerrit.client.rpc.NativeMap;
 import com.google.gerrit.client.rpc.RestApi;
@@ -25,11 +26,15 @@
 import com.google.gwt.user.client.rpc.AsyncCallback;
 
 public class DiffApi {
-  public static void list(int id, String base, String revision,
+  public static void list(int id, String revision, RevisionInfo base,
       AsyncCallback<NativeMap<FileInfo>> cb) {
     RestApi api = ChangeApi.revision(id, revision).view("files");
     if (base != null) {
-      api.addParameter("base", base);
+      if (base._number() < 0) {
+        api.addParameter("parent", -base._number());
+      } else {
+        api.addParameter("base", base.name());
+      }
     }
     api.get(NativeMap.copyKeysIntoChildren("path", cb));
   }
@@ -38,7 +43,11 @@
       AsyncCallback<NativeMap<FileInfo>> cb) {
     RestApi api = ChangeApi.revision(id).view("files");
     if (base != null) {
-      api.addParameter("base", base.get());
+      if (base.get() < 0) {
+        api.addParameter("parent", -base.get());
+      } else {
+        api.addParameter("base", base.get());
+      }
     }
     api.get(NativeMap.copyKeysIntoChildren("path", cb));
   }
@@ -57,7 +66,11 @@
 
   public DiffApi base(PatchSet.Id id) {
     if (id != null) {
-      call.addParameter("base", id.get());
+      if (id.get() < 0) {
+        call.addParameter("parent", -id.get());
+      } else {
+        call.addParameter("base", id.get());
+      }
     }
     return this;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
index 2264871..8935e36 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffScreen.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.client.diff.DiffInfo.FileMeta;
 import com.google.gerrit.client.diff.LineMapper.LineOnOtherInfo;
 import com.google.gerrit.client.info.ChangeInfo;
+import com.google.gerrit.client.info.ChangeInfo.CommitInfo;
 import com.google.gerrit.client.info.ChangeInfo.EditInfo;
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.info.FileInfo;
@@ -116,6 +117,7 @@
   private List<HandlerRegistration> handlers;
   private PreferencesAction prefsAction;
   private int reloadVersionId;
+  private int parents;
 
   @UiField(provided = true)
   Header header;
@@ -213,6 +215,8 @@
         new CommentsCollections(base, revision, path);
     comments.load(group2);
 
+    countParents(group2);
+
     RestApi call = ChangeApi.detail(changeId.get());
     ChangeList.addOptions(call, EnumSet.of(
         ListChangesOption.ALL_REVISIONS));
@@ -231,7 +235,7 @@
             revision.get() == info.revision(currentRevision)._number();
         JsArray<RevisionInfo> list = info.revisions().values();
         RevisionInfo.sortRevisionInfoByNumber(list);
-        getDiffTable().set(prefs, list, diff, edit != null, current,
+        getDiffTable().set(prefs, list, parents, diff, edit != null, current,
             changeStatus.isOpen(), diff.binary());
         header.setChangeInfo(info);
       }
@@ -245,6 +249,22 @@
         getScreenLoadCallback(comments)));
   }
 
+  private void countParents(CallbackGroup cbg) {
+    ChangeApi.revision(changeId.get(), revision.getId())
+        .view("commit")
+        .get(cbg.add(new AsyncCallback<CommitInfo>() {
+          @Override
+          public void onSuccess(CommitInfo info) {
+            parents = info.parents().length();
+          }
+
+          @Override
+          public void onFailure(Throwable caught) {
+            parents = 0;
+          }
+        }));
+  }
+
   @Override
   public void onShowView() {
     super.onShowView();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
index 4374986..392ad2f 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/DiffTable.java
@@ -108,12 +108,12 @@
     patchSetSelectBoxB.setUpBlame(cm, false, rev, path);
   }
 
-  void set(DiffPreferences prefs, JsArray<RevisionInfo> list, DiffInfo info,
+  void set(DiffPreferences prefs, JsArray<RevisionInfo> list, int parents, DiffInfo info,
       boolean editExists, boolean current, boolean open, boolean binary) {
     this.changeType = info.changeType();
-    patchSetSelectBoxA.setUpPatchSetNav(list, info.metaA(), editExists,
+    patchSetSelectBoxA.setUpPatchSetNav(list, parents, info.metaA(), editExists,
         current, open, binary);
-    patchSetSelectBoxB.setUpPatchSetNav(list, info.metaB(), editExists,
+    patchSetSelectBoxB.setUpPatchSetNav(list, parents, info.metaB(), editExists,
         current, open, binary);
 
     JsArrayString hdr = info.diffHeader();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
index 39b85cf..bc37abb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/PatchSetSelectBox.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.blame.BlameInfo;
 import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.Util;
 import com.google.gerrit.client.info.ChangeInfo.RevisionInfo;
 import com.google.gerrit.client.info.WebLinkInfo;
 import com.google.gerrit.client.patches.PatchUtil;
@@ -87,13 +88,29 @@
     this.path = path;
   }
 
-  void setUpPatchSetNav(JsArray<RevisionInfo> list, DiffInfo.FileMeta meta,
+  void setUpPatchSetNav(JsArray<RevisionInfo> list, int parents, DiffInfo.FileMeta meta,
       boolean editExists, boolean current, boolean open, boolean binary) {
-    InlineHyperlink baseLink = null;
     InlineHyperlink selectedLink = null;
     if (sideA) {
-      baseLink = createLink(PatchUtil.C.patchBase(), null);
-      linkPanel.add(baseLink);
+      if (parents <= 1) {
+        InlineHyperlink link = createLink(PatchUtil.C.patchBase(), null);
+        linkPanel.add(link);
+        selectedLink = link;
+      } else {
+        for (int i = parents; i > 0; i--) {
+          PatchSet.Id id = new PatchSet.Id(changeId, -i);
+          InlineHyperlink link = createLink(Util.M.diffBaseParent(i), id);
+          linkPanel.add(link);
+          if (revision != null && id.equals(revision)) {
+            selectedLink = link;
+          }
+        }
+        InlineHyperlink link = createLink(Util.C.autoMerge(), null);
+        linkPanel.add(link);
+        if (selectedLink == null) {
+          selectedLink = link;
+        }
+      }
     }
     for (int i = 0; i < list.length(); i++) {
       RevisionInfo r = list.get(i);
@@ -106,8 +123,6 @@
     }
     if (selectedLink != null) {
       selectedLink.setStyleName(style.selected());
-    } else if (sideA) {
-      baseLink.setStyleName(style.selected());
     }
 
     if (meta == null) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
index 2b83b71..bcb7dac 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/SideBySideCommentManager.java
@@ -84,6 +84,7 @@
       addDraftBox(cm.side(), CommentInfo.create(
               getPath(),
               getStoredSideFromDisplaySide(cm.side()),
+              getParentNumFromDisplaySide(cm.side()),
               line,
               CommentRange.create(fromTo))).setEdit(true);
       cm.setCursor(fromTo.to());
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 4dd21ae..f80cc49 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -66,7 +66,7 @@
     CmdLineParser clp = parserFactory.create(param);
     try {
       clp.parseOptionMap(in);
-    } catch (CmdLineException e) {
+    } catch (CmdLineException | NumberFormatException e) {
       if (!clp.wasHelpRequestedByOption()) {
         replyError(req, res, SC_BAD_REQUEST, e.getMessage(), e);
         return false;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
index 77c466e..b9aab3a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -55,9 +55,11 @@
 
   @Override
   public void run() throws IOException {
-    ui.header("Index");
-
-    IndexType type = index.select("Type", "type", IndexType.LUCENE);
+    IndexType type = IndexType.LUCENE;
+    if (IndexType.values().length > 1) {
+      ui.header("Index");
+      type = index.select("Type", "type", type);
+    }
     for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
       AbstractLuceneIndex.setReady(
           site, def.getName(), def.getLatest().getVersion(), true);
@@ -65,7 +67,10 @@
     if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
       // Do nothing
     } else {
-      final String message = String.format(
+      if (IndexType.values().length <= 1) {
+        ui.header("Index");
+      }
+      String message = String.format(
         "\nThe index must be %sbuilt before starting Gerrit:\n"
         + "  java -jar gerrit.war reindex -d site_path\n",
         site.isNew ? "" : "re");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
index 1c72600..6739ce0 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -19,48 +19,29 @@
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
 import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.VersionedMetaDataOnInit;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AuthorizedKeys;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.git.VersionedMetaData;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.FS;
 
-import java.io.File;
 import java.io.IOException;
-import java.nio.file.Path;
 import java.util.List;
 
-public class VersionedAuthorizedKeysOnInit extends VersionedMetaData {
+public class VersionedAuthorizedKeysOnInit extends VersionedMetaDataOnInit {
   public interface Factory {
     VersionedAuthorizedKeysOnInit create(Account.Id accountId);
   }
 
   private final Account.Id accountId;
-  private final String ref;
-  private final String project;
-  private final SitePaths site;
-  private final InitFlags flags;
-
   private List<Optional<AccountSshKey>> keys;
-  private ObjectId revision;
 
   @Inject
   public VersionedAuthorizedKeysOnInit(
@@ -68,41 +49,19 @@
       SitePaths site,
       InitFlags flags,
       @Assisted Account.Id accountId) {
-
-    this.project = allUsers.get();
-    this.site = site;
-    this.flags = flags;
+    super(flags, site, allUsers.get(), RefNames.refsUsers(accountId));
     this.accountId = accountId;
-    this.ref = RefNames.refsUsers(accountId);
   }
 
   @Override
-  protected String getRefName() {
-    return ref;
-  }
-
   public VersionedAuthorizedKeysOnInit load()
       throws IOException, ConfigInvalidException {
-    File path = getPath();
-    if (path != null) {
-      try (Repository repo = new FileRepository(path)) {
-        load(repo);
-      }
-    }
+    super.load();
     return this;
   }
 
-  private File getPath() {
-    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    if (basePath == null) {
-      throw new IllegalStateException("gerrit.basePath must be configured");
-    }
-    return FileKey.resolve(basePath.resolve(project).toFile(), FS.DETECTED);
-  }
-
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
-    revision = getRevision();
     keys = AuthorizedKeys.parse(accountId, readUTF8(AuthorizedKeys.FILE_NAME));
   }
 
@@ -116,50 +75,6 @@
     return key;
   }
 
-  public void save(String message) throws IOException {
-    save(new PersonIdent("Gerrit Initialization", "init@gerrit"), message);
-  }
-
-  private void save(PersonIdent ident, String msg) throws IOException {
-    File path = getPath();
-    if (path == null) {
-      throw new IOException(project + " does not exist.");
-    }
-
-    try (Repository repo = new FileRepository(path);
-        ObjectInserter i = repo.newObjectInserter();
-        ObjectReader r = repo.newObjectReader();
-        RevWalk rw = new RevWalk(reader)) {
-      inserter = i;
-      reader = r;
-
-      RevTree srcTree = revision != null ? rw.parseTree(revision) : null;
-      newTree = readTree(srcTree);
-
-      CommitBuilder commit = new CommitBuilder();
-      commit.setAuthor(ident);
-      commit.setCommitter(ident);
-      commit.setMessage(msg);
-
-      onSave(commit);
-      ObjectId res = newTree.writeTree(inserter);
-      if (res.equals(srcTree)) {
-        return;
-      }
-
-      commit.setTreeId(res);
-      if (revision != null) {
-        commit.addParentId(revision);
-      }
-      ObjectId newRevision = inserter.insert(commit);
-      updateRef(repo, ident, newRevision, "commit: " + msg);
-      revision = newRevision;
-    } finally {
-      inserter = null;
-      reader = null;
-    }
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException {
     if (Strings.isNullOrEmpty(commit.getMessage())) {
@@ -169,30 +84,4 @@
     saveUTF8(AuthorizedKeys.FILE_NAME, AuthorizedKeys.serialize(keys));
     return true;
   }
-
-  private void updateRef(Repository repo, PersonIdent ident,
-      ObjectId newRevision, String refLogMsg) throws IOException {
-    RefUpdate ru = repo.updateRef(getRefName());
-    ru.setRefLogIdent(ident);
-    ru.setNewObjectId(newRevision);
-    ru.setExpectedOldObjectId(revision);
-    ru.setRefLogMessage(refLogMsg, false);
-    RefUpdate.Result r = ru.update();
-    switch(r) {
-      case FAST_FORWARD:
-      case NEW:
-      case NO_CHANGE:
-        break;
-      case FORCED:
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      default:
-        throw new IOException("Failed to update " + getRefName() + " of "
-            + project + ": " + r.name());
-    }
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index f7d9d4a..a7ebd33 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -18,73 +18,32 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GroupList;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.git.VersionedMetaData;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileRepository;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.RepositoryCache;
-import org.eclipse.jgit.lib.RepositoryCache.FileKey;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.util.FS;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-import java.io.File;
 import java.io.IOException;
-import java.nio.file.Path;
 
-public class AllProjectsConfig extends VersionedMetaData {
+public class AllProjectsConfig extends VersionedMetaDataOnInit {
 
   private static final Logger log = LoggerFactory.getLogger(AllProjectsConfig.class);
 
-  private final String project;
-  private final SitePaths site;
-  private final InitFlags flags;
-
   private Config cfg;
-  private ObjectId revision;
   private GroupList groupList;
 
   @Inject
   AllProjectsConfig(AllProjectsNameOnInitProvider allProjects, SitePaths site,
       InitFlags flags) {
-    this.project = allProjects.get();
-    this.site = site;
-    this.flags = flags;
+    super(flags, site, allProjects.get(), RefNames.REFS_CONFIG);
 
   }
 
-  @Override
-  protected String getRefName() {
-    return RefNames.REFS_CONFIG;
-  }
-
-  private File getPath() {
-    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
-    if (basePath == null) {
-      throw new IllegalStateException("gerrit.basePath must be configured");
-    }
-    return FileKey.resolve(basePath.resolve(project).toFile(), FS.DETECTED);
-  }
-
-  public AllProjectsConfig load() throws IOException, ConfigInvalidException {
-    File path = getPath();
-    if (path != null) {
-      try (Repository repo = new FileRepository(path)) {
-        load(repo);
-      }
-    }
-    return this;
-  }
-
   public Config getConfig() {
     return cfg;
   }
@@ -94,10 +53,16 @@
   }
 
   @Override
+  public AllProjectsConfig load()
+      throws IOException, ConfigInvalidException {
+    super.load();
+    return this;
+  }
+
+  @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     groupList = readGroupList();
     cfg = readConfig(ProjectConfig.PROJECT_CONFIG);
-    revision = getRevision();
   }
 
   private GroupList readGroupList() throws IOException {
@@ -105,96 +70,31 @@
         GroupList.createLoggerSink(GroupList.FILE_NAME, log));
   }
 
-  @Override
-  protected boolean onSave(CommitBuilder commit) throws IOException,
-      ConfigInvalidException {
-    throw new UnsupportedOperationException();
-  }
-
-  public void save(String message) throws IOException {
-    save(new PersonIdent("Gerrit Initialization", "init@gerrit"), message);
-  }
-
-  public void save(String pluginName, String message) throws IOException {
+  public void save(String pluginName, String message)
+      throws IOException, ConfigInvalidException {
     save(new PersonIdent(pluginName, pluginName + "@gerrit"),
         "Update from plugin " + pluginName + ": " + message);
   }
 
-  private void save(PersonIdent ident, String msg) throws IOException {
-    File path = getPath();
-    if (path == null) {
-      throw new IOException("All-Projects does not exist.");
-    }
-
-    try (Repository repo = new FileRepository(path)) {
-      inserter = repo.newObjectInserter();
-      reader = repo.newObjectReader();
-      try (RevWalk rw = new RevWalk(reader)) {
-        RevTree srcTree = revision != null ? rw.parseTree(revision) : null;
-        newTree = readTree(srcTree);
-        saveConfig(ProjectConfig.PROJECT_CONFIG, cfg);
-        saveGroupList();
-        ObjectId res = newTree.writeTree(inserter);
-        if (res.equals(srcTree)) {
-          // If there are no changes to the content, don't create the commit.
-          return;
-        }
-
-        CommitBuilder commit = new CommitBuilder();
-        commit.setAuthor(ident);
-        commit.setCommitter(ident);
-        commit.setMessage(msg);
-        commit.setTreeId(res);
-        if (revision != null) {
-          commit.addParentId(revision);
-        }
-        ObjectId newRevision = inserter.insert(commit);
-        updateRef(repo, ident, newRevision, "commit: " + msg);
-        revision = newRevision;
-      } finally {
-        if (inserter != null) {
-          inserter.close();
-          inserter = null;
-        }
-        if (reader != null) {
-          reader.close();
-          reader = null;
-        }
-      }
-    }
+  @Override
+  protected void save(PersonIdent ident, String msg)
+      throws IOException, ConfigInvalidException {
+    super.save(ident, msg);
 
     // we need to invalidate the JGit cache if the group list is invalidated in
     // an unattended init step
     RepositoryCache.clear();
   }
 
-  private void saveGroupList() throws IOException {
-    saveUTF8(GroupList.FILE_NAME, groupList.asText());
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException,
+      ConfigInvalidException {
+    saveConfig(ProjectConfig.PROJECT_CONFIG, cfg);
+    saveGroupList();
+    return true;
   }
 
-  private void updateRef(Repository repo, PersonIdent ident,
-      ObjectId newRevision, String refLogMsg) throws IOException {
-    RefUpdate ru = repo.updateRef(getRefName());
-    ru.setRefLogIdent(ident);
-    ru.setNewObjectId(newRevision);
-    ru.setExpectedOldObjectId(revision);
-    ru.setRefLogMessage(refLogMsg, false);
-    RefUpdate.Result r = ru.update();
-    switch (r) {
-      case FAST_FORWARD:
-      case NEW:
-      case NO_CHANGE:
-        break;
-      case FORCED:
-      case IO_FAILURE:
-      case LOCK_FAILURE:
-      case NOT_ATTEMPTED:
-      case REJECTED:
-      case REJECTED_CURRENT_BRANCH:
-      case RENAMED:
-      default:
-        throw new IOException("Failed to update " + getRefName() + " of "
-            + project + ": " + r.name());
-    }
+  private void saveGroupList() throws IOException {
+    saveUTF8(GroupList.FILE_NAME, groupList.asText());
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
new file mode 100644
index 0000000..b953a0b
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -0,0 +1,148 @@
+// 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.pgm.init.api;
+
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.VersionedMetaData;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.FS;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+
+public abstract class VersionedMetaDataOnInit extends VersionedMetaData {
+
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String project;
+  private final String ref;
+
+  protected VersionedMetaDataOnInit(InitFlags flags, SitePaths site,
+      String project, String ref) {
+    this.flags = flags;
+    this.site = site;
+    this.project = project;
+    this.ref = ref;
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  public VersionedMetaDataOnInit load()
+      throws IOException, ConfigInvalidException {
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path)) {
+        load(repo);
+      }
+    }
+    return this;
+  }
+
+  public void save(String message) throws IOException, ConfigInvalidException {
+    save(new PersonIdent("Gerrit Initialization", "init@gerrit"), message);
+  }
+
+  protected void save(PersonIdent ident, String msg)
+      throws IOException, ConfigInvalidException {
+    File path = getPath();
+    if (path == null) {
+      throw new IOException(project + " does not exist.");
+    }
+
+    try (Repository repo = new FileRepository(path);
+        ObjectInserter i = repo.newObjectInserter();
+        ObjectReader r = repo.newObjectReader();
+        RevWalk rw = new RevWalk(r)) {
+      inserter = i;
+      reader = r;
+
+      RevTree srcTree = revision != null ? rw.parseTree(revision) : null;
+      newTree = readTree(srcTree);
+
+      CommitBuilder commit = new CommitBuilder();
+      commit.setAuthor(ident);
+      commit.setCommitter(ident);
+      commit.setMessage(msg);
+
+      onSave(commit);
+
+      ObjectId res = newTree.writeTree(inserter);
+      if (res.equals(srcTree)) {
+        return;
+      }
+      commit.setTreeId(res);
+
+      if (revision != null) {
+        commit.addParentId(revision);
+      }
+      ObjectId newRevision = inserter.insert(commit);
+      updateRef(repo, ident, newRevision, "commit: " + msg);
+      revision = rw.parseCommit(newRevision);
+    } finally {
+      inserter = null;
+      reader = null;
+    }
+  }
+
+  private void updateRef(Repository repo, PersonIdent ident,
+      ObjectId newRevision, String refLogMsg) throws IOException {
+    RefUpdate ru = repo.updateRef(getRefName());
+    ru.setRefLogIdent(ident);
+    ru.setNewObjectId(newRevision);
+    ru.setExpectedOldObjectId(revision);
+    ru.setRefLogMessage(refLogMsg, false);
+    RefUpdate.Result r = ru.update();
+    switch(r) {
+      case FAST_FORWARD:
+      case NEW:
+      case NO_CHANGE:
+        break;
+      case FORCED:
+      case IO_FAILURE:
+      case LOCK_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case RENAMED:
+      default:
+        throw new IOException("Failed to update " + getRefName() + " of "
+            + project + ": " + r.name());
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    if (basePath == null) {
+      throw new IllegalStateException("gerrit.basePath must be configured");
+    }
+    return FileKey.resolve(basePath.resolve(project).toFile(), FS.DETECTED);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index a573625..eebf8e0 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -27,6 +27,8 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountByEmailCacheImpl;
 import com.google.gerrit.server.account.AccountCacheImpl;
+import com.google.gerrit.server.account.AccountVisibility;
+import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupCacheImpl;
@@ -157,5 +159,8 @@
 
     bind(ChangeJson.Factory.class).toProvider(
         Providers.<ChangeJson.Factory>of(null));
+    bind(AccountVisibility.class)
+        .toProvider(AccountVisibilityProvider.class)
+        .in(SINGLETON);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index 9034e47..c493ccd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -100,10 +100,9 @@
       PatchListCache plCache = env.getArgs().getPatchListCache();
       Change change = getChange(engine);
       Project.NameKey project = change.getProject();
-      ObjectId a = null;
       ObjectId b = ObjectId.fromString(ps.getRevision().get());
       Whitespace ws = Whitespace.IGNORE_NONE;
-      PatchListKey plKey = new PatchListKey(a, b, ws);
+      PatchListKey plKey = PatchListKey.againstDefaultBase(b, ws);
       PatchList patchList;
       try {
         patchList = plCache.get(plKey, project);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 847d559..902a51c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server;
 
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
 import com.google.common.annotations.VisibleForTesting;
@@ -51,6 +52,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Date;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -122,7 +124,7 @@
   }
 
   /**
-   * Get all reviewers for a change.
+   * Get all reviewers and CCed accounts for a change.
    *
    * @param allApprovals all approvals to consider; must all belong to the same
    *     change.
@@ -151,8 +153,16 @@
       ChangeUpdate update, LabelTypes labelTypes, Change change,
       Iterable<Account.Id> wantReviewers) throws OrmException {
     PatchSet.Id psId = change.currentPatchSetId();
+    Collection<Account.Id> existingReviewers;
+    if (migration.readChanges()) {
+      // If using NoteDB, we only want reviewers in the REVIEWER state.
+      existingReviewers = notes.load().getReviewers().byState(REVIEWER);
+    } else {
+      // Prior to NoteDB, we gather all reviewers regardless of state.
+      existingReviewers = getReviewers(db, notes).all();
+    }
     return addReviewers(db, update, labelTypes, change, psId, false, null, null,
-        wantReviewers, getReviewers(db, notes).all());
+        wantReviewers, existingReviewers);
   }
 
   private List<PatchSetApproval> addReviewers(ReviewDb db, ChangeUpdate update,
@@ -191,6 +201,30 @@
     return Collections.unmodifiableList(cells);
   }
 
+  /**
+   * Adds accounts to a change as reviewers in the CC state.
+   *
+   * @param notes change notes.
+   * @param update change update.
+   * @param wantCCs accounts to CC.
+   * @return whether a change was made.
+   * @throws OrmException
+   */
+  public Collection<Account.Id> addCcs(ChangeNotes notes, ChangeUpdate update,
+      Collection<Account.Id> wantCCs) throws OrmException {
+    return addCcs(update, wantCCs, notes.load().getReviewers());
+  }
+
+  private Collection<Account.Id> addCcs(ChangeUpdate update,
+      Collection<Account.Id> wantCCs, ReviewerSet existingReviewers) {
+    Set<Account.Id> need = new LinkedHashSet<>(wantCCs);
+    need.removeAll(existingReviewers.all());
+    for (Account.Id account : need) {
+      update.putReviewer(account, CC);
+    }
+    return need;
+  }
+
   public void addApprovals(ReviewDb db, ChangeUpdate update,
       LabelTypes labelTypes, PatchSet ps, ChangeControl changeCtl,
       Map<String, Short> approvals) throws OrmException {
@@ -317,6 +351,6 @@
             .append(LabelVote.create(e.getKey(), e.getValue()).format());
       }
     }
-    return msgs.append('.').toString();
+    return msgs.toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
index d990115..603f528 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchLineCommentsUtil.java
@@ -365,9 +365,17 @@
         "cannot set RevId for patch set %s on comment %s", ps.getId(), c);
     if (c.getRevId() == null) {
       try {
-        c.setRevId(Side.fromShort(c.getSide()) == Side.PARENT
-            ? new RevId(ObjectId.toString(cache.getOldId(change, ps)))
-            : ps.getRevision());
+        if (Side.fromShort(c.getSide()) == Side.PARENT) {
+          if (c.getSide() < 0) {
+            c.setRevId(new RevId(ObjectId.toString(
+                cache.getOldId(change, ps, -c.getSide()))));
+          } else {
+            c.setRevId(new RevId(ObjectId.toString(
+                cache.getOldId(change, ps, null))));
+          }
+        } else {
+          c.setRevId(ps.getRevision());
+        }
       } catch (PatchListNotAvailableException e) {
         throw new OrmException(e);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
index 57ffd0a..0856616 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
@@ -21,9 +21,12 @@
 import com.google.gerrit.reviewdb.client.AccountExternalId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
@@ -84,10 +87,16 @@
 
   static class Loader extends CacheLoader<String, Set<Account.Id>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final AccountIndexCollection accountIndexes;
+    private final Provider<InternalAccountQuery> accountQueryProvider;
 
     @Inject
-    Loader(final SchemaFactory<ReviewDb> schema) {
+    Loader(SchemaFactory<ReviewDb> schema,
+        AccountIndexCollection accountIndexes,
+        Provider<InternalAccountQuery> accountQueryProvider) {
       this.schema = schema;
+      this.accountIndexes = accountIndexes;
+      this.accountQueryProvider = accountQueryProvider;
     }
 
     @Override
@@ -97,9 +106,18 @@
         for (Account a : db.accounts().byPreferredEmail(email)) {
           r.add(a.getId());
         }
-        for (AccountExternalId a : db.accountExternalIds()
-            .byEmailAddress(email)) {
-          r.add(a.getAccountId());
+        if (accountIndexes.getSearchIndex() != null) {
+          for (AccountState accountState : accountQueryProvider.get()
+              .byExternalId(
+                  (new AccountExternalId.Key(AccountExternalId.SCHEME_MAILTO,
+                      email)).get())) {
+            r.add(accountState.getAccount().getId());
+          }
+        } else {
+          for (AccountExternalId a : db.accountExternalIds()
+              .byEmailAddress(email)) {
+            r.add(a.getAccountId());
+          }
         }
         return ImmutableSet.copyOf(r);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
index 337cd7c..e348e73 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
@@ -263,20 +263,7 @@
     }
 
     rules = capabilities.getPermission(permissionName);
-
-    if (rules.isEmpty()) {
-      effective.put(permissionName, rules);
-      return rules;
-    }
-
     GroupMembership groups = user.getEffectiveGroups();
-    if (rules.size() == 1) {
-      if (!match(groups, rules.get(0))) {
-        rules = Collections.emptyList();
-      }
-      effective.put(permissionName, rules);
-      return rules;
-    }
 
     List<PermissionRule> mine = new ArrayList<>(rules.size());
     for (PermissionRule rule : rules) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index 3d40373..e6ca18df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -75,6 +75,15 @@
   }
 
   @Override
+  public DiffInfo diff(int parent) throws RestApiException {
+    try {
+      return getDiff.setParent(parent).apply(file).value();
+    } catch (OrmException | InvalidChangeOperationException | IOException e) {
+      throw new RestApiException("Cannot retrieve diff", e);
+    }
+  }
+
+  @Override
   public DiffRequest diffRequest() {
     return new DiffRequest() {
       @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 4c7adea..6b5e83c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -166,7 +166,7 @@
   public void review(ReviewInput in) throws RestApiException {
     try {
       review.apply(revision, in);
-    } catch (OrmException | UpdateException e) {
+    } catch (OrmException | UpdateException | IOException e) {
       throw new RestApiException("Cannot post review", e);
     }
   }
@@ -309,6 +309,17 @@
     }
   }
 
+  @SuppressWarnings("unchecked")
+  @Override
+  public Map<String, FileInfo> files(int parentNum) throws RestApiException {
+    try {
+      return (Map<String, FileInfo>) listFiles.setParent(parentNum)
+          .apply(revision).value();
+    } catch (OrmException | IOException e) {
+      throw new RestApiException("Cannot retrieve files", e);
+    }
+  }
+
   @Override
   public FileApi file(String path) {
     return fileApi.create(files.parse(revision,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index ce38186..158758b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -355,7 +355,21 @@
   }
 
   private ChangeInfo checkOnly(ChangeData cd) {
-    ConsistencyChecker.Result result = checkerProvider.get().check(cd, fix);
+    ChangeControl ctl;
+    try {
+      ctl = cd.changeControl().forUser(userProvider.get());
+    } catch (OrmException e) {
+      String msg = "Error loading change";
+      log.warn(msg + " " + cd.getId(), e);
+      ChangeInfo info = new ChangeInfo();
+      info._number = cd.getId().get();
+      ProblemInfo p = new ProblemInfo();
+      p.message = msg;
+      info.problems = Lists.newArrayList(p);
+      return info;
+    }
+
+    ConsistencyChecker.Result result = checkerProvider.get().check(ctl, fix);
     ChangeInfo info;
     Change c = result.change();
     if (c != null) {
@@ -384,9 +398,11 @@
       Optional<PatchSet.Id> limitToPsId) throws PatchListNotAvailableException,
       GpgException, OrmException, IOException {
     ChangeInfo out = new ChangeInfo();
+    CurrentUser user = userProvider.get();
+    ChangeControl ctl = cd.changeControl().forUser(user);
 
     if (has(CHECK)) {
-      out.problems = checkerProvider.get().check(cd.change(), fix).problems();
+      out.problems = checkerProvider.get().check(ctl, fix).problems();
       // If any problems were fixed, the ChangeData needs to be reloaded.
       for (ProblemInfo p : out.problems) {
         if (p.status == ProblemInfo.Status.FIXED) {
@@ -397,8 +413,6 @@
     }
 
     Change in = cd.change();
-    CurrentUser user = userProvider.get();
-    ChangeControl ctl = cd.changeControl().forUser(user);
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
     out.topic = in.getTopic();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
index 465ce95..f4869be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
@@ -18,12 +18,10 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -32,27 +30,22 @@
 
 public class Check implements RestReadView<ChangeResource>,
     RestModifyView<ChangeResource, FixInput> {
-  private final NotesMigration notesMigration;
   private final ChangeJson.Factory jsonFactory;
 
   @Inject
-  Check(NotesMigration notesMigration,
-      ChangeJson.Factory json) {
-    this.notesMigration = notesMigration;
+  Check(ChangeJson.Factory json) {
     this.jsonFactory = json;
   }
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc)
       throws RestApiException, OrmException {
-    checkEnabled();
     return Response.withMustRevalidate(newChangeJson().format(rsrc));
   }
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
       throws RestApiException, OrmException {
-    checkEnabled();
     ChangeControl ctl = rsrc.getControl();
     if (!ctl.isOwner()
         && !ctl.getProjectControl().isOwner()
@@ -65,10 +58,4 @@
   private ChangeJson newChangeJson() {
     return jsonFactory.create(EnumSet.of(ListChangesOption.CHECK));
   }
-
-  private void checkEnabled() throws NotImplementedException {
-    if (notesMigration.readChanges()) {
-      throw new NotImplementedException("check not implemented for NoteDb");
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
index 0af5656..d1ce453 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
@@ -126,8 +126,11 @@
     }
     r.id = Url.encode(c.getKey().get());
     r.path = c.getKey().getParentKey().getFileName();
-    if (c.getSide() == 0) {
+    if (c.getSide() <= 0) {
       r.side = Side.PARENT;
+      if (c.getSide() < 0) {
+        r.parent = -c.getSide();
+      }
     }
     if (c.getLine() > 0) {
       r.line = c.getLine();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index c0a92ba..b16d8b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.intKeyOrdering;
 import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
@@ -39,23 +40,22 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -78,9 +78,10 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
+import java.util.Set;
 
 /**
  * Checks changes for various kinds of inconsistency and corruption.
@@ -94,12 +95,10 @@
 
   @AutoValue
   public abstract static class Result {
-    private static Result create(Change.Id id, List<ProblemInfo> problems) {
-      return new AutoValue_ConsistencyChecker_Result(id, null, problems);
-    }
-
-    private static Result create(Change c, List<ProblemInfo> problems) {
-      return new AutoValue_ConsistencyChecker_Result(c.getId(), c, problems);
+    private static Result create(ChangeControl ctl,
+        List<ProblemInfo> problems) {
+      return new AutoValue_ConsistencyChecker_Result(
+          ctl.getId(), ctl.getChange(), problems);
     }
 
     public abstract Change.Id id();
@@ -110,22 +109,20 @@
     public abstract List<ProblemInfo> problems();
   }
 
-  private final Provider<ReviewDb> db;
-  private final GitRepositoryManager repoManager;
-  private final NotesMigration notesMigration;
-  private final Provider<CurrentUser> user;
-  private final Provider<PersonIdent> serverIdent;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final PatchSetInserter.Factory patchSetInserterFactory;
   private final BatchUpdate.Factory updateFactory;
-  private final ChangeIndexer indexer;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
-  private final ChangeUpdate.Factory changeUpdateFactory;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
+  private final GitRepositoryManager repoManager;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final PatchSetUtil psUtil;
+  private final Provider<CurrentUser> user;
+  private final Provider<PersonIdent> serverIdent;
+  private final Provider<ReviewDb> db;
 
   private FixInput fix;
-  private Change change;
+  private ChangeControl ctl;
   private Repository repo;
   private RevWalk rw;
 
@@ -137,67 +134,51 @@
   private List<ProblemInfo> problems;
 
   @Inject
-  ConsistencyChecker(Provider<ReviewDb> db,
-      GitRepositoryManager repoManager,
-      NotesMigration notesMigration,
-      Provider<CurrentUser> user,
+  ConsistencyChecker(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      PatchSetInfoFactory patchSetInfoFactory,
-      PatchSetInserter.Factory patchSetInserterFactory,
       BatchUpdate.Factory updateFactory,
-      ChangeIndexer indexer,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory,
-      ChangeUpdate.Factory changeUpdateFactory,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore) {
+      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
+      GitRepositoryManager repoManager,
+      PatchSetInfoFactory patchSetInfoFactory,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      PatchSetUtil psUtil,
+      Provider<CurrentUser> user,
+      Provider<ReviewDb> db) {
+    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.changeControlFactory = changeControlFactory;
     this.db = db;
-    this.notesMigration = notesMigration;
-    this.repoManager = repoManager;
-    this.user = user;
-    this.serverIdent = serverIdent;
+    this.notesFactory = notesFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
+    this.psUtil = psUtil;
+    this.repoManager = repoManager;
+    this.serverIdent = serverIdent;
     this.updateFactory = updateFactory;
-    this.indexer = indexer;
-    this.changeControlFactory = changeControlFactory;
-    this.notesFactory = notesFactory;
-    this.changeUpdateFactory = changeUpdateFactory;
-    this.accountPatchReviewStore = accountPatchReviewStore;
+    this.user = user;
     reset();
   }
 
   private void reset() {
-    change = null;
+    ctl = null;
     repo = null;
     rw = null;
     problems = new ArrayList<>();
   }
 
-  public Result check(ChangeData cd) {
-    return check(cd, null);
+  private Change change() {
+    return ctl.getChange();
   }
 
-  public Result check(ChangeData cd, @Nullable FixInput f) {
-    reset();
+  public Result check(ChangeControl cc, @Nullable FixInput f) {
+    checkNotNull(cc);
     try {
-      return check(cd.change(), f);
-    } catch (OrmException e) {
-      error("Error looking up change", e);
-      return Result.create(cd.getId(), problems);
-    }
-  }
-
-  public Result check(Change c) {
-    return check(c, null);
-  }
-
-  public Result check(Change c, @Nullable FixInput f) {
-    reset();
-    fix = f;
-    change = c;
-    try {
+      reset();
+      ctl = cc;
+      fix = f;
       checkImpl();
-      return Result.create(c, problems);
+      return result();
     } finally {
       if (rw != null) {
         rw.close();
@@ -209,8 +190,6 @@
   }
 
   private void checkImpl() {
-    checkState(!notesMigration.readChanges(),
-        "ConsistencyChecker for NoteDb not yet implemented");
     checkOwner();
     checkCurrentPatchSetEntity();
 
@@ -226,8 +205,8 @@
 
   private void checkOwner() {
     try {
-      if (db.get().accounts().get(change.getOwner()) == null) {
-        problem("Missing change owner: " + change.getOwner());
+      if (db.get().accounts().get(change().getOwner()) == null) {
+        problem("Missing change owner: " + change().getOwner());
       }
     } catch (OrmException e) {
       error("Failed to look up owner", e);
@@ -236,10 +215,10 @@
 
   private void checkCurrentPatchSetEntity() {
     try {
-      PatchSet.Id psId = change.currentPatchSetId();
-      currPs = db.get().patchSets().get(psId);
+      currPs = psUtil.current(db.get(), ctl.getNotes());
       if (currPs == null) {
-        problem(String.format("Current patch set %d not found", psId.get()));
+        problem(String.format("Current patch set %d not found",
+              change().currentPatchSetId().get()));
       }
     } catch (OrmException e) {
       error("Failed to look up current patch set", e);
@@ -247,7 +226,7 @@
   }
 
   private boolean openRepo() {
-    Project.NameKey project = change.getDest().getParentKey();
+    Project.NameKey project = change().getDest().getParentKey();
     try {
       repo = repoManager.openRepository(project);
       rw = new RevWalk(repo);
@@ -262,13 +241,11 @@
   private boolean checkPatchSets() {
     List<PatchSet> all;
     try {
-      all = Lists.newArrayList(db.get().patchSets().byChange(change.getId()));
+      // Iterate in descending order.
+      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(db.get(), ctl.getNotes()));
     } catch (OrmException e) {
       return error("Failed to look up patch sets", e);
     }
-    // Iterate in descending order so deletePatchSet can assume the latest patch
-    // set exists.
-    Collections.sort(all, PS_ID_ORDER.reverse());
     patchSetsBySha = MultimapBuilder.hashKeys(all.size())
         .treeSetValues(PS_ID_ORDER)
         .build();
@@ -287,6 +264,7 @@
       refs = Collections.emptyMap();
     }
 
+    List<DeletePatchSetFromDbOp> deletePatchSetOps = new ArrayList<>();
     for (PatchSet ps : all) {
       // Check revision format.
       int psNum = ps.getId().get();
@@ -317,17 +295,21 @@
           objId, String.format("patch set %d", psNum));
       if (psCommit == null) {
         if (fix != null && fix.deletePatchSetIfCommitMissing) {
-          deletePatchSet(lastProblem(), change.getProject(), ps.getId());
+          deletePatchSetOps.add(
+              new DeletePatchSetFromDbOp(lastProblem(), ps.getId()));
         }
         continue;
       } else if (refProblem != null && fix != null) {
         fixPatchSetRef(refProblem, ps);
       }
-      if (ps.getId().equals(change.currentPatchSetId())) {
+      if (ps.getId().equals(change().currentPatchSetId())) {
         currPsCommit = psCommit;
       }
     }
 
+    // Delete any bad patch sets found above, in a single update.
+    deletePatchSets(deletePatchSetOps);
+
     // Check for duplicates.
     for (Map.Entry<ObjectId, Collection<PatchSet>> e
         : patchSetsBySha.asMap().entrySet()) {
@@ -342,7 +324,7 @@
   }
 
   private void checkMerged() {
-    String refName = change.getDest().get();
+    String refName = change().getDest().get();
     Ref dest;
     try {
       dest = repo.getRefDatabase().exactRef(refName);
@@ -375,22 +357,27 @@
     }
   }
 
+  private ProblemInfo wrongChangeStatus(PatchSet.Id psId, RevCommit commit) {
+    String refName = change().getDest().get();
+    return problem(String.format(
+        "Patch set %d (%s) is merged into destination ref %s (%s), but change"
+        + " status is %s", psId.get(), commit.name(),
+        refName, tip.name(), change().getStatus()));
+  }
+
   private void checkMergedBitMatchesStatus(PatchSet.Id psId, RevCommit commit,
       boolean merged) {
-    String refName = change.getDest().get();
-    if (merged && change.getStatus() != Change.Status.MERGED) {
-      ProblemInfo p = problem(String.format(
-          "Patch set %d (%s) is merged into destination ref %s (%s), but change"
-          + " status is %s", psId.get(), commit.name(),
-          refName, tip.name(), change.getStatus()));
+    String refName = change().getDest().get();
+    if (merged && change().getStatus() != Change.Status.MERGED) {
+      ProblemInfo p = wrongChangeStatus(psId, commit);
       if (fix != null) {
         fixMerged(p);
       }
-    } else if (!merged && change.getStatus() == Change.Status.MERGED) {
+    } else if (!merged && change().getStatus() == Change.Status.MERGED) {
       problem(String.format("Patch set %d (%s) is not merged into"
             + " destination ref %s (%s), but change status is %s",
             currPs.getId().get(), commit.name(), refName, tip.name(),
-            change.getStatus()));
+            change().getStatus()));
     }
   }
 
@@ -401,16 +388,12 @@
     if (commit == null) {
       return;
     }
-    if (Objects.equals(commit, currPsCommit)) {
-      // Caller gave us latest patch set SHA-1; verified in checkPatchSets.
-      return;
-    }
 
     try {
       if (!rw.isMergedInto(commit, tip)) {
         problem(String.format("Expected merged commit %s is not merged into"
               + " destination ref %s (%s)",
-              commit.name(), change.getDest().get(), tip.name()));
+              commit.name(), change().getDest().get(), tip.name()));
         return;
       }
 
@@ -425,8 +408,8 @@
         }
         try {
           Change c = notesFactory.createChecked(
-              db.get(), change.getProject(), psId.getParentKey()).getChange();
-          if (!c.getDest().equals(change.getDest())) {
+              db.get(), change().getProject(), psId.getParentKey()).getChange();
+          if (!c.getDest().equals(change().getDest())) {
             continue;
           }
         } catch (OrmException | NoSuchChangeException e) {
@@ -442,16 +425,17 @@
           String changeId = Iterables.getFirst(
               commit.getFooterLines(FooterConstants.CHANGE_ID), null);
           // Missing Change-Id footer is ok, but mismatched is not.
-          if (changeId != null && !changeId.equals(change.getKey().get())) {
+          if (changeId != null && !changeId.equals(change().getKey().get())) {
             problem(String.format("Expected merged commit %s has Change-Id: %s,"
                   + " but expected %s",
-                  commit.name(), changeId, change.getKey().get()));
+                  commit.name(), changeId, change().getKey().get()));
             return;
           }
-          PatchSet.Id psId = insertPatchSet(commit);
-          if (psId != null) {
-            checkMergedBitMatchesStatus(psId, commit, true);
-          }
+          // TODO(dborowitz): Combine into one op.
+          insertPatchSet(commit);
+          fixMerged(problem(String.format(
+              "Expected merged commit %s has no associated patch set",
+              commit.name())));
           break;
 
         case 1:
@@ -460,10 +444,12 @@
           // TODO(dborowitz): This could be fixed if it's an older patch set of
           // the current change.
           PatchSet.Id id = psIds.get(0);
-          if (!id.equals(change.currentPatchSetId())) {
+          if (id.equals(change().currentPatchSetId())) {
+            fixMerged(wrongChangeStatus(id, commit));
+          } else {
             problem(String.format("Expected merged commit %s corresponds to"
                   + " patch set %s, which is not the current patch set %s",
-                  commit.name(), id, change.currentPatchSetId()));
+                  commit.name(), id, change().currentPatchSetId()));
           }
           break;
 
@@ -490,17 +476,14 @@
     }
 
     try {
-      ChangeControl ctl = changeControlFactory
-          .controlFor(db.get(), change, user.get());
       PatchSet.Id psId =
-          ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
+          ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
       PatchSetInserter inserter =
           patchSetInserterFactory.create(ctl, psId, commit);
-      try (BatchUpdate bu = updateFactory.create(
-            db.get(), change.getProject(), ctl.getUser(), TimeUtil.nowTs());
+      try (BatchUpdate bu = newBatchUpdate();
           ObjectInserter oi = repo.newObjectInserter()) {
         bu.setRepository(repo, rw, oi);
-        bu.addOp(change.getId(), inserter
+        bu.addOp(ctl.getId(), inserter
             .setValidatePolicy(CommitValidators.Policy.NONE)
             .setFireRevisionCreated(false)
             .setSendMail(false)
@@ -509,7 +492,8 @@
                 "Patch set for merged commit inserted by consistency checker"));
         bu.execute();
       }
-      change = inserter.getChange();
+      ctl = changeControlFactory.controlFor(
+          db.get(), inserter.getChange(), ctl.getUser());
       p.status = Status.FIXED;
       p.outcome = "Inserted as patch set " + psId.get();
       return psId;
@@ -523,30 +507,33 @@
   }
 
   private void fixMerged(ProblemInfo p) {
-    try {
-      change = db.get().changes().atomicUpdate(change.getId(),
-          new AtomicUpdate<Change>() {
-            @Override
-            public Change update(Change c) {
-              c.setStatus(Change.Status.MERGED);
-              return c;
-            }
-          });
-      ChangeUpdate changeUpdate =
-          changeUpdateFactory.create(
-              changeControlFactory.controlFor(db.get(), change, user.get()));
-      changeUpdate.fixStatus(Change.Status.MERGED);
-      changeUpdate.commit();
-      indexer.index(db.get(), change);
+    try (BatchUpdate bu = newBatchUpdate();
+        ObjectInserter oi = repo.newObjectInserter()) {
+      bu.setRepository(repo, rw, oi);
+      bu.addOp(ctl.getId(), new BatchUpdate.Op() {
+        @Override
+        public boolean updateChange(ChangeContext ctx) throws OrmException {
+          ctx.getChange().setStatus(Change.Status.MERGED);
+          ctx.getUpdate(ctx.getChange().currentPatchSetId())
+            .fixStatus(Change.Status.MERGED);
+          return true;
+        }
+      });
+      bu.execute();
       p.status = Status.FIXED;
       p.outcome = "Marked change as merged";
-    } catch (OrmException | IOException | NoSuchChangeException e) {
-      log.warn("Error marking " + change.getId() + "as merged", e);
+    } catch (UpdateException | RestApiException e) {
+      log.warn("Error marking " + ctl.getId() + "as merged", e);
       p.status = Status.FIX_FAILED;
       p.outcome = "Error updating status to merged";
     }
   }
 
+  private BatchUpdate newBatchUpdate() {
+    return updateFactory.create(
+        db.get(), change().getProject(), ctl.getUser(), TimeUtil.nowTs());
+  }
+
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
     try {
       RefUpdate ru = repo.updateRef(ps.getId().toRefName());
@@ -582,59 +569,108 @@
     }
   }
 
-  private void deletePatchSet(ProblemInfo p, Project.NameKey project,
-      PatchSet.Id psId) {
-    ReviewDb db = this.db.get();
-    Change.Id cid = psId.getParentKey();
-    try {
-      db.changes().beginTransaction(cid);
-      try {
-        ChangeNotes notes = notesFactory.createChecked(db, project, cid);
-        Change c = notes.getChange();
-        if (psId.equals(c.currentPatchSetId())) {
-          List<PatchSet> all = Lists.newArrayList(db.patchSets().byChange(cid));
-          if (all.size() == 1 && all.get(0).getId().equals(psId)) {
-            p.status = Status.FIX_FAILED;
-            p.outcome = "Cannot delete patch set; no patch sets would remain";
-            return;
-          }
-          // If there were multiple missing patch sets, assumes deletePatchSet
-          // has been called in decreasing order, so the max remaining PatchSet
-          // is the effective current patch set.
-          Collections.sort(all, PS_ID_ORDER.reverse());
-          PatchSet.Id latest = null;
-          for (PatchSet ps : all) {
-            latest = ps.getId();
-            if (!ps.getId().equals(psId)) {
-              break;
-            }
-          }
-          c.setCurrentPatchSet(patchSetInfoFactory.get(db, notes, latest));
-          db.changes().update(Collections.singleton(c));
-        }
-
-        // Delete dangling primary key references. Don't delete ChangeMessages,
-        // which don't use patch sets as a primary key, and may provide useful
-        // historical information.
-        accountPatchReviewStore.get().clearReviewed(psId);
-        db.patchSetApprovals().delete(
-            db.patchSetApprovals().byPatchSet(psId));
-        db.patchComments().delete(
-            db.patchComments().byPatchSet(psId));
-        db.patchSets().deleteKeys(Collections.singleton(psId));
-        db.commit();
-
-        p.status = Status.FIXED;
-        p.outcome = "Deleted patch set";
-      } finally {
-        db.rollback();
+  private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
+    try (BatchUpdate bu = newBatchUpdate();
+        ObjectInserter oi = repo.newObjectInserter()) {
+      bu.setRepository(repo, rw, oi);
+      for (DeletePatchSetFromDbOp op : ops) {
+        checkArgument(op.psId.getParentKey().equals(ctl.getId()));
+        bu.addOp(ctl.getId(), op);
       }
-    } catch (PatchSetInfoNotAvailableException | OrmException
-        | NoSuchChangeException e) {
+      bu.addOp(ctl.getId(), new UpdateCurrentPatchSetOp(ops));
+      bu.execute();
+    } catch (NoPatchSetsWouldRemainException e) {
+      for (DeletePatchSetFromDbOp op : ops) {
+        op.p.status = Status.FIX_FAILED;
+        op.p.outcome = e.getMessage();
+      }
+    } catch (UpdateException | RestApiException e) {
       String msg = "Error deleting patch set";
-      log.warn(msg + ' ' + psId, e);
-      p.status = Status.FIX_FAILED;
-      p.outcome = msg;
+      log.warn(msg + " of change " + ops.get(0).psId.getParentKey(), e);
+      for (DeletePatchSetFromDbOp op : ops) {
+        // Overwrite existing statuses that were set before the transaction was
+        // rolled back.
+        op.p.status = Status.FIX_FAILED;
+        op.p.outcome = msg;
+      }
+    }
+  }
+
+  private class DeletePatchSetFromDbOp extends BatchUpdate.Op {
+    private final ProblemInfo p;
+    private final PatchSet.Id psId;
+
+    private DeletePatchSetFromDbOp(ProblemInfo p, PatchSet.Id psId) {
+      this.p = p;
+      this.psId = psId;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, PatchSetInfoNotAvailableException {
+      // Delete dangling key references.
+      ReviewDb db = DeleteDraftChangeOp.unwrap(ctx.getDb());
+      accountPatchReviewStore.get().clearReviewed(psId);
+      db.changeMessages().delete(
+          db.changeMessages().byChange(psId.getParentKey()));
+      db.patchSetApprovals().delete(
+          db.patchSetApprovals().byPatchSet(psId));
+      db.patchComments().delete(
+          db.patchComments().byPatchSet(psId));
+      db.patchSets().deleteKeys(Collections.singleton(psId));
+
+      // NoteDb requires no additional fiddling; setting the state to deleted is
+      // sufficient to filter everything else out.
+      ctx.getUpdate(psId).setPatchSetState(PatchSetState.DELETED);
+
+      p.status = Status.FIXED;
+      p.outcome = "Deleted patch set";
+      return true;
+    }
+  }
+
+  private static class NoPatchSetsWouldRemainException
+      extends RestApiException {
+    private static final long serialVersionUID = 1L;
+
+    private NoPatchSetsWouldRemainException() {
+      super("Cannot delete patch set; no patch sets would remain");
+    }
+  }
+
+  private class UpdateCurrentPatchSetOp extends BatchUpdate.Op {
+    private final Set<PatchSet.Id> toDelete;
+
+    private UpdateCurrentPatchSetOp(List<DeletePatchSetFromDbOp> deleteOps) {
+      toDelete = new HashSet<>();
+      for (DeletePatchSetFromDbOp op : deleteOps) {
+        toDelete.add(op.psId);
+      }
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws OrmException, PatchSetInfoNotAvailableException,
+        NoPatchSetsWouldRemainException {
+      if (!toDelete.contains(ctx.getChange().currentPatchSetId())) {
+        return false;
+      }
+      Set<PatchSet.Id> all = new HashSet<>();
+      // Doesn't make any assumptions about the order in which deletes happen
+      // and whether they are seen by this op; we are already given the full set
+      // of patch sets that will eventually be deleted in this update.
+      for (PatchSet ps : psUtil.byChange(ctx.getDb(), ctx.getNotes())) {
+        if (!toDelete.contains(ps.getId())) {
+          all.add(ps.getId());
+        }
+      }
+      if (all.isEmpty()) {
+        throw new NoPatchSetsWouldRemainException();
+      }
+      PatchSet.Id latest = ReviewDbUtil.intKeyOrdering().max(all);
+      ctx.getChange().setCurrentPatchSet(
+          patchSetInfoFactory.get(ctx.getDb(), ctx.getNotes(), latest));
+      return true;
     }
   }
 
@@ -687,6 +723,10 @@
   }
 
   private void warn(Throwable t) {
-    log.warn("Error in consistency check of change " + change.getId(), t);
+    log.warn("Error in consistency check of change " + ctl.getId(), t);
+  }
+
+  private Result result() {
+    return Result.create(ctl, problems);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 68b3199..7cb2aac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -15,11 +15,11 @@
 package com.google.gerrit.server.change;
 
 import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.change.PutDraftComment.side;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
-import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -119,7 +119,7 @@
               ChangeUtil.messageUUID(ctx.getDb())),
           line, ctx.getAccountId(), Url.decode(in.inReplyTo),
           ctx.getWhen());
-      comment.setSide(in.side == Side.PARENT ? (short) 0 : (short) 1);
+      comment.setSide(side(in));
       comment.setMessage(in.message.trim());
       comment.setRange(in.range);
       comment.setTag(in.tag);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
index 45794fb..e0591f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.server.util.GitUtil.getParent;
+
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.common.FileInfo;
@@ -21,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
@@ -29,17 +32,24 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 
+import java.io.IOException;
 import java.util.Map;
 import java.util.TreeMap;
 
 @Singleton
 public class FileInfoJson {
   private final PatchListCache patchListCache;
+  private final GitRepositoryManager repoManager;
 
   @Inject
-  FileInfoJson(PatchListCache patchListCache) {
+  FileInfoJson(
+      PatchListCache patchListCache,
+      GitRepositoryManager repoManager) {
+    this.repoManager = repoManager;
     this.patchListCache = patchListCache;
   }
 
@@ -54,6 +64,22 @@
         ? null
         : ObjectId.fromString(base.getRevision().get());
     ObjectId b = ObjectId.fromString(revision.get());
+    return toFileInfoMap(change, a, b);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
+      throws RepositoryNotFoundException, IOException,
+          PatchListNotAvailableException {
+    ObjectId b = ObjectId.fromString(revision.get());
+    ObjectId a;
+    try (Repository git = repoManager.openRepository(change.getProject())) {
+      a = getParent(git, b, parent);
+    }
+    return toFileInfoMap(change, a, b);
+  }
+
+  private Map<String, FileInfo> toFileInfoMap(Change change,
+      ObjectId a, ObjectId b) throws PatchListNotAvailableException {
     PatchList list = patchListCache.get(
         new PatchListKey(a, b, Whitespace.IGNORE_NONE), change.getProject());
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index 7f74967..35dbec1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -95,6 +95,9 @@
     @Option(name = "--base", metaVar = "revision-id")
     String base;
 
+    @Option(name = "--parent", metaVar = "parent-number")
+    int parentNum;
+
     @Option(name = "--reviewed")
     boolean reviewed;
 
@@ -145,24 +148,33 @@
         return Response.ok(query(resource));
       }
 
-      PatchSet basePatchSet = null;
-      if (base != null) {
-        RevisionResource baseResource = revisions.parse(
-            resource.getChangeResource(), IdString.fromDecoded(base));
-        basePatchSet = baseResource.getPatchSet();
-      }
+      Response<Map<String, FileInfo>> r;
       try {
-        Response<Map<String, FileInfo>> r = Response.ok(fileInfoJson.toFileInfoMap(
-            resource.getChange(),
-            resource.getPatchSet().getRevision(),
-            basePatchSet));
-        if (resource.isCacheable()) {
-          r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+        if (base != null) {
+          RevisionResource baseResource = revisions.parse(
+              resource.getChangeResource(), IdString.fromDecoded(base));
+          r = Response.ok(fileInfoJson.toFileInfoMap(
+              resource.getChange(),
+              resource.getPatchSet().getRevision(),
+              baseResource.getPatchSet()));
+        } else if (parentNum > 0) {
+          r = Response.ok(fileInfoJson.toFileInfoMap(
+              resource.getChange(),
+              resource.getPatchSet().getRevision(),
+              parentNum - 1));
+        } else {
+          r = Response.ok(fileInfoJson.toFileInfoMap(
+              resource.getChange(),
+              resource.getPatchSet()));
         }
-        return r;
       } catch (PatchListNotAvailableException e) {
         throw new ResourceNotFoundException(e.getMessage());
       }
+
+      if (resource.isCacheable()) {
+        r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
+      }
+      return r;
     }
 
     private void checkOptions() throws BadRequestException {
@@ -170,6 +182,9 @@
       if (base != null) {
         supplied++;
       }
+      if (parentNum > 0) {
+        supplied++;
+      }
       if (reviewed) {
         supplied++;
       }
@@ -177,7 +192,8 @@
         supplied++;
       }
       if (supplied > 1) {
-        throw new BadRequestException("cannot combine base, reviewed, query");
+        throw new BadRequestException(
+            "cannot combine base, parent, reviewed, query");
       }
     }
 
@@ -306,5 +322,10 @@
       this.base = base;
       return this;
     }
+
+    public ListFiles setParent(int parentNum) {
+      this.parentNum = parentNum;
+      return this;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
index 3d02b83..1728c35 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
@@ -90,6 +90,9 @@
   @Option(name = "--base", metaVar = "REVISION")
   String base;
 
+  @Option(name = "--parent", metaVar = "parent-number")
+  int parentNum;
+
   @Deprecated
   @Option(name = "--ignore-whitespace")
   IgnoreWhitespace ignoreWhitespace;
@@ -121,12 +124,6 @@
   public Response<DiffInfo> apply(FileResource resource)
       throws ResourceConflictException, ResourceNotFoundException,
       OrmException, AuthException, InvalidChangeOperationException, IOException {
-    PatchSet basePatchSet = null;
-    if (base != null) {
-      RevisionResource baseResource = revisions.parse(
-          resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
-      basePatchSet = baseResource.getPatchSet();
-    }
     DiffPreferencesInfo prefs = new DiffPreferencesInfo();
     if (whitespace != null) {
       prefs.ignoreWhitespace = whitespace;
@@ -138,13 +135,35 @@
     prefs.context = context;
     prefs.intralineDifference = intraline;
 
-    try {
-      PatchScriptFactory psf = patchScriptFactoryFactory.create(
+    PatchScriptFactory psf;
+    PatchSet basePatchSet = null;
+    if (base != null) {
+      RevisionResource baseResource = revisions.parse(
+          resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
+      basePatchSet = baseResource.getPatchSet();
+      psf = patchScriptFactoryFactory.create(
           resource.getRevision().getControl(),
           resource.getPatchKey().getFileName(),
-          basePatchSet != null ? basePatchSet.getId() : null,
+          basePatchSet.getId(),
           resource.getPatchKey().getParentKey(),
           prefs);
+    } else if (parentNum > 0) {
+      psf = patchScriptFactoryFactory.create(
+          resource.getRevision().getControl(),
+          resource.getPatchKey().getFileName(),
+          parentNum - 1,
+          resource.getPatchKey().getParentKey(),
+          prefs);
+    } else {
+      psf = patchScriptFactoryFactory.create(
+          resource.getRevision().getControl(),
+          resource.getPatchKey().getFileName(),
+          null,
+          resource.getPatchKey().getParentKey(),
+          prefs);
+    }
+
+    try {
       psf.setLoadHistory(false);
       psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
       PatchScript ps = psf.call();
@@ -272,6 +291,11 @@
     return this;
   }
 
+  public GetDiff setParent(int parentNum) {
+    this.parentNum = parentNum;
+    return this;
+  }
+
   public GetDiff setContext(int context) {
     this.context = context;
     return this;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 2fbf552..4e8527a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
+import static com.google.gerrit.server.change.PutDraftComment.side;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
@@ -24,6 +25,8 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.common.hash.HashCode;
@@ -34,10 +37,13 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -79,6 +85,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -94,10 +101,6 @@
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
   private static final Logger log = LoggerFactory.getLogger(PostReview.class);
 
-  static class Output {
-    Map<String, Short> labels;
-  }
-
   private final Provider<ReviewDb> db;
   private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangesCollection changes;
@@ -110,6 +113,7 @@
   private final AccountsCollection accounts;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
+  private final PostReviewers postReviewers;
 
   @Inject
   PostReview(Provider<ReviewDb> db,
@@ -123,7 +127,8 @@
       PatchListCache patchListCache,
       AccountsCollection accounts,
       EmailReviewComments.Factory email,
-      CommentAdded commentAdded) {
+      CommentAdded commentAdded,
+      PostReviewers postReviewers) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
@@ -136,16 +141,18 @@
     this.accounts = accounts;
     this.email = email;
     this.commentAdded = commentAdded;
+    this.postReviewers = postReviewers;
   }
 
   @Override
-  public Output apply(RevisionResource revision, ReviewInput input)
-      throws RestApiException, UpdateException, OrmException {
+  public ReviewResult apply(RevisionResource revision, ReviewInput input)
+      throws RestApiException, UpdateException, OrmException, IOException {
     return apply(revision, input, TimeUtil.nowTs());
   }
 
-  public Output apply(RevisionResource revision, ReviewInput input,
-      Timestamp ts) throws RestApiException, UpdateException, OrmException {
+  public ReviewResult apply(RevisionResource revision, ReviewInput input,
+      Timestamp ts)
+      throws RestApiException, UpdateException, OrmException, IOException {
     // Respect timestamp, but truncate at change created-on time.
     ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
     if (revision.getEdit().isPresent()) {
@@ -165,15 +172,52 @@
       input.notify = NotifyHandling.NONE;
     }
 
+    Map<String, AddReviewerResult> reviewerJsonResults = null;
+    List<PostReviewers.Addition> reviewerResults = Lists.newArrayList();
+    boolean hasError = false;
+    boolean confirm = false;
+    if (input.reviewers != null) {
+      reviewerJsonResults = Maps.newHashMap();
+      for (AddReviewerInput reviewerInput : input.reviewers) {
+        PostReviewers.Addition result = postReviewers.prepareApplication(
+            revision.getChangeResource(), reviewerInput);
+        reviewerJsonResults.put(reviewerInput.reviewer, result.result);
+        if (result.result.error != null) {
+          hasError = true;
+          continue;
+        }
+        if (result.result.confirm != null) {
+          confirm = true;
+          continue;
+        }
+        reviewerResults.add(result);
+      }
+    }
+
+    ReviewResult output = new ReviewResult();
+    output.reviewers = reviewerJsonResults;
+    if (hasError || confirm) {
+      return output;
+    }
+    output.labels = input.labels;
+
     try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
           revision.getChange().getProject(), revision.getUser(), ts)) {
+      // Apply reviewer changes first. Revision emails should be sent to the
+      // updated set of reviewers.
+      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+        bu.addOp(revision.getChange().getId(), reviewerResult.op);
+      }
       bu.addOp(
           revision.getChange().getId(),
           new Op(revision.getPatchSet().getId(), input));
       bu.execute();
+
+      for (PostReviewers.Addition reviewerResult : reviewerResults) {
+        reviewerResult.gatherResults();
+      }
     }
-    Output output = new Output();
-    output.labels = input.labels;
+
     return output;
   }
 
@@ -426,7 +470,7 @@
           }
           e.setStatus(PatchLineComment.Status.PUBLISHED);
           e.setWrittenOn(ctx.getWhen());
-          e.setSide(c.side == Side.PARENT ? (short) 0 : (short) 1);
+          e.setSide(side(c));
           setCommentRevId(e, patchListCache, ctx.getChange(), ps);
           e.setMessage(c.message);
           e.setTag(in.tag);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index 8205beb..399c3ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -14,6 +14,9 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
@@ -23,6 +26,7 @@
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -49,6 +53,7 @@
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.mail.AddReviewerSender;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gwtorm.server.OrmException;
@@ -62,15 +67,18 @@
 
 import java.io.IOException;
 import java.text.MessageFormat;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
 @Singleton
-public class PostReviewers implements RestModifyView<ChangeResource, AddReviewerInput> {
-  private static final Logger log = LoggerFactory
-      .getLogger(PostReviewers.class);
+public class PostReviewers
+    implements RestModifyView<ChangeResource, AddReviewerInput> {
+  private static final Logger log =
+      LoggerFactory.getLogger(PostReviewers.class);
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
@@ -91,6 +99,7 @@
   private final AccountCache accountCache;
   private final ReviewerJson json;
   private final ReviewerAdded reviewerAdded;
+  private final NotesMigration migration;
 
   @Inject
   PostReviewers(AccountsCollection accounts,
@@ -108,7 +117,8 @@
       @GerritServerConfig Config cfg,
       AccountCache accountCache,
       ReviewerJson json,
-      ReviewerAdded reviewerAdded) {
+      ReviewerAdded reviewerAdded,
+      NotesMigration migration) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
     this.approvalsUtil = approvalsUtil;
@@ -125,49 +135,64 @@
     this.accountCache = accountCache;
     this.json = json;
     this.reviewerAdded = reviewerAdded;
+    this.migration = migration;
   }
 
   @Override
   public AddReviewerResult apply(ChangeResource rsrc, AddReviewerInput input)
-      throws UpdateException, OrmException, RestApiException, IOException {
+      throws IOException, OrmException, RestApiException, UpdateException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
 
+    Addition addition = prepareApplication(rsrc, input);
+    if (addition.op == null) {
+      return addition.result;
+    }
+    try (BatchUpdate bu = batchUpdateFactory.create(dbProvider.get(),
+        rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      Change.Id id = rsrc.getChange().getId();
+      bu.addOp(id, addition.op);
+      bu.execute();
+      addition.gatherResults();
+    }
+    return addition.result;
+  }
+
+  public Addition prepareApplication(ChangeResource rsrc, AddReviewerInput input)
+      throws OrmException, RestApiException, IOException {
     try {
       Account.Id accountId = accounts.parse(input.reviewer).getAccountId();
-      return putAccount(input.reviewer, reviewerFactory.create(rsrc, accountId));
+      return putAccount(input.reviewer, reviewerFactory.create(rsrc, accountId),
+          input.state());
     } catch (UnprocessableEntityException e) {
       try {
         return putGroup(rsrc, input);
       } catch (UnprocessableEntityException e2) {
-        throw new UnprocessableEntityException(MessageFormat.format(
-            ChangeMessages.get().reviewerNotFound,
-            input.reviewer));
+        throw new UnprocessableEntityException(MessageFormat
+            .format(ChangeMessages.get().reviewerNotFound, input.reviewer));
       }
     }
   }
 
-  private AddReviewerResult putAccount(String reviewer, ReviewerResource rsrc)
-      throws OrmException, UpdateException, RestApiException {
+  private Addition putAccount(String reviewer, ReviewerResource rsrc,
+      ReviewerState state) {
     Account member = rsrc.getReviewerUser().getAccount();
     ChangeControl control = rsrc.getReviewerControl();
-    AddReviewerResult result = new AddReviewerResult(reviewer);
     if (isValidReviewer(member, control)) {
-      addReviewers(rsrc.getChangeResource(), result,
-          ImmutableMap.of(member.getId(), control));
+      return new Addition(reviewer, rsrc.getChangeResource(),
+          ImmutableMap.of(member.getId(), control), state);
     }
-    return result;
+    return new Addition(reviewer);
   }
 
-  private AddReviewerResult putGroup(ChangeResource rsrc, AddReviewerInput input)
-      throws UpdateException, RestApiException, OrmException, IOException {
-    GroupDescription.Basic group = groupsCollection.parseInternal(input.reviewer);
-    AddReviewerResult result = new AddReviewerResult(input.reviewer);
+  private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
+      throws RestApiException, OrmException, IOException {
+    GroupDescription.Basic group =
+        groupsCollection.parseInternal(input.reviewer);
     if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      result.error = MessageFormat.format(
-          ChangeMessages.get().groupIsNotAllowed, group.getName());
-      return result;
+      return fail(input.reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed,
+          group.getName()));
     }
 
     Map<Account.Id, ChangeControl> reviewers = new HashMap<>();
@@ -187,22 +212,19 @@
     int maxAllowed =
         cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
     if (maxAllowed > 0 && members.size() > maxAllowed) {
-      result.error = MessageFormat.format(
-          ChangeMessages.get().groupHasTooManyMembers, group.getName());
-      return result;
+      return fail(input.reviewer, MessageFormat.format(
+          ChangeMessages.get().groupHasTooManyMembers, group.getName()));
     }
 
     // if maxWithoutCheck is set to 0, we never ask for confirmation
-    int maxWithoutConfirmation =
-        cfg.getInt("addreviewer", "maxWithoutConfirmation",
-            DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+    int maxWithoutConfirmation = cfg.getInt("addreviewer",
+        "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
     if (!input.confirmed() && maxWithoutConfirmation > 0
         && members.size() > maxWithoutConfirmation) {
-      result.confirm = true;
-      result.error = MessageFormat.format(
-          ChangeMessages.get().groupManyMembersConfirmation,
-          group.getName(), members.size());
-      return result;
+      return fail(input.reviewer, true,
+          MessageFormat.format(
+              ChangeMessages.get().groupManyMembersConfirmation,
+              group.getName(), members.size()));
     }
 
     for (Account member : members) {
@@ -211,8 +233,7 @@
       }
     }
 
-    addReviewers(rsrc, result, reviewers);
-    return result;
+    return new Addition(input.reviewer, rsrc, reviewers, input.state());
   }
 
   private boolean isValidReviewer(Account member, ChangeControl control) {
@@ -225,80 +246,127 @@
     return false;
   }
 
+  private Addition fail(String reviewer, String error) {
+    return fail(reviewer, false, error);
+  }
 
-  private void addReviewers(
-      ChangeResource rsrc, AddReviewerResult result, Map<Account.Id, ChangeControl> reviewers)
-      throws OrmException, RestApiException, UpdateException {
-    try (BatchUpdate bu = batchUpdateFactory.create(
-            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      Op op = new Op(rsrc, reviewers);
-      Change.Id id = rsrc.getChange().getId();
-      bu.addOp(id, op);
-      bu.execute();
+  private Addition fail(String reviewer, boolean confirm, String error) {
+    Addition addition = new Addition(reviewer);
+    addition.result.confirm = confirm ? true : null;
+    addition.result.error = error;
+    return addition;
+  }
 
-      result.reviewers = Lists.newArrayListWithCapacity(op.added.size());
-      for (PatchSetApproval psa : op.added) {
-        // New reviewers have value 0, don't bother normalizing.
-        result.reviewers.add(
-          json.format(new ReviewerInfo(psa.getAccountId().get()),
-              reviewers.get(psa.getAccountId()),
-              ImmutableList.of(psa)));
+  class Addition {
+    final AddReviewerResult result;
+    final Op op;
+
+    private final Map<Account.Id, ChangeControl> reviewers;
+
+    protected Addition(String reviewer) {
+      this(reviewer, null, null, REVIEWER);
+    }
+
+    protected Addition(String reviewer, ChangeResource rsrc,
+        Map<Account.Id, ChangeControl> reviewers, ReviewerState state) {
+      result = new AddReviewerResult(reviewer);
+      if (reviewers == null) {
+        this.reviewers = ImmutableMap.of();
+        op = null;
+        return;
       }
+      this.reviewers = reviewers;
+      op = new Op(rsrc, reviewers, state);
+    }
 
-      // We don't do this inside Op, since the accounts are in a different
-      // table.
-      accountLoaderFactory.create(true).fill(result.reviewers);
+    void gatherResults() throws OrmException {
+      // Generate result details and fill AccountLoader. This occurs outside
+      // the Op because the accounts are in a different table.
+      if (migration.readChanges() && op.state == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(op.addedCCs.size());
+        for (Account.Id accountId : op.addedCCs) {
+          result.ccs.add(
+              json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
+        }
+        accountLoaderFactory.create(true).fill(result.ccs);
+      } else {
+        result.reviewers = Lists.newArrayListWithCapacity(op.addedReviewers.size());
+        for (PatchSetApproval psa : op.addedReviewers) {
+          // New reviewers have value 0, don't bother normalizing.
+          result.reviewers.add(
+            json.format(new ReviewerInfo(psa.getAccountId().get()),
+                reviewers.get(psa.getAccountId()),
+                ImmutableList.of(psa)));
+        }
+        accountLoaderFactory.create(true).fill(result.reviewers);
+      }
     }
   }
 
-  private class Op extends BatchUpdate.Op {
-    private final ChangeResource rsrc;
-    private final Map<Account.Id, ChangeControl> reviewers;
+  class Op extends BatchUpdate.Op {
+    final Map<Account.Id, ChangeControl> reviewers;
+    final ReviewerState state;
+    List<PatchSetApproval> addedReviewers;
+    Collection<Account.Id> addedCCs;
 
-    private List<PatchSetApproval> added;
+    private final ChangeResource rsrc;
     private PatchSet patchSet;
 
-    Op(ChangeResource rsrc, Map<Account.Id, ChangeControl> reviewers) {
+    Op(ChangeResource rsrc, Map<Account.Id, ChangeControl> reviewers,
+        ReviewerState state) {
       this.rsrc = rsrc;
       this.reviewers = reviewers;
+      this.state = state;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx)
         throws RestApiException, OrmException, IOException {
-      added =
-          approvalsUtil.addReviewers(
-              ctx.getDb(),
-              ctx.getNotes(),
-              ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-              rsrc.getControl().getLabelTypes(),
-              rsrc.getChange(),
-              reviewers.keySet());
-
-      if (added.isEmpty()) {
-        return false;
+      if (migration.readChanges() && state == CC) {
+        addedCCs = approvalsUtil.addCcs(ctx.getNotes(),
+            ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+            reviewers.keySet());
+        if (addedCCs.isEmpty()) {
+          return false;
+        }
+      } else {
+        addedReviewers = approvalsUtil.addReviewers(ctx.getDb(), ctx.getNotes(),
+            ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+            rsrc.getControl().getLabelTypes(), rsrc.getChange(),
+            reviewers.keySet());
+        if (addedReviewers.isEmpty()) {
+          return false;
+        }
       }
+
       patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
       return true;
     }
 
     @Override
     public void postUpdate(Context ctx) throws Exception {
-      emailReviewers(rsrc.getChange(), added);
-
-      if (!added.isEmpty()) {
-        for (PatchSetApproval psa : added) {
-          Account account = accountCache.get(psa.getAccountId()).getAccount();
-          reviewerAdded.fire(rsrc.getChange(), patchSet, account,
-              ctx.getAccount(),
-              ctx.getWhen());
+      if (addedReviewers != null || addedCCs != null) {
+        if (addedReviewers == null) {
+          addedReviewers = new ArrayList<>();
+        }
+        if (addedCCs == null) {
+          addedCCs = new ArrayList<>();
+        }
+        emailReviewers(rsrc.getChange(), addedReviewers, addedCCs);
+        if (!addedReviewers.isEmpty()) {
+          for (PatchSetApproval psa : addedReviewers) {
+            Account account = accountCache.get(psa.getAccountId()).getAccount();
+            reviewerAdded.fire(rsrc.getChange(), patchSet, account,
+              ctx.getAccount(), ctx.getWhen());
+          }
         }
       }
     }
   }
 
-  private void emailReviewers(Change change, List<PatchSetApproval> added) {
-    if (added.isEmpty()) {
+  private void emailReviewers(Change change, List<PatchSetApproval> added,
+      Collection<Account.Id> copied) {
+    if (added.isEmpty() && copied.isEmpty()) {
       return;
     }
 
@@ -312,18 +380,27 @@
         toMail.add(psa.getAccountId());
       }
     }
-    if (!toMail.isEmpty()) {
-      try {
-        AddReviewerSender cm = addReviewerSenderFactory
-            .create(change.getProject(), change.getId());
-        cm.setFrom(userId);
-        cm.addReviewers(toMail);
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot send email to new reviewers of change "
-            + change.getId(), err);
+    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
+    for (Account.Id id : copied) {
+      if (!id.equals(userId)) {
+        toCopy.add(id);
       }
     }
+    if (toMail.isEmpty() && toCopy.isEmpty()) {
+      return;
+    }
+
+    try {
+      AddReviewerSender cm = addReviewerSenderFactory
+          .create(change.getProject(), change.getId());
+      cm.setFrom(userId);
+      cm.addReviewers(toMail);
+      cm.addExtraCC(toCopy);
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot send email to new reviewers of change "
+          + change.getId(), err);
+    }
   }
 
   public static boolean isLegalReviewerGroup(AccountGroup.UUID groupUUID) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index 043beaa..655e07d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Optional;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -163,7 +164,7 @@
   private static PatchLineComment update(PatchLineComment e, DraftInput in,
       Timestamp when) {
     if (in.side != null) {
-      e.setSide(in.side == Side.PARENT ? (short) 0 : (short) 1);
+      e.setSide(side(in));
     }
     if (in.inReplyTo != null) {
       e.setParentUuid(Url.decode(in.inReplyTo));
@@ -180,4 +181,11 @@
     }
     return e;
   }
+
+  static short side(Comment c) {
+    if (c.side == Side.PARENT) {
+      return (short) (c.parent == null ? 0 : -c.parent.shortValue());
+    }
+    return 1;
+  }
 }
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 5b1fd25..bd5dbda 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
@@ -17,6 +17,9 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
@@ -41,6 +44,8 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -59,6 +64,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -276,7 +282,7 @@
     }
   }
 
-  public static class Op {
+  public static class RepoOnlyOp {
     /**
      * Override this method to update the repo.
      *
@@ -286,6 +292,18 @@
     }
 
     /**
+     * Override this method to do something after the update
+     * e.g. send email or run hooks
+     *
+     * @param ctx context
+     */
+    //TODO(dborowitz): Support async operations?
+    public void postUpdate(Context ctx) throws Exception {
+    }
+  }
+
+  public static class Op extends RepoOnlyOp {
+    /**
      * Override this method to modify a change.
      *
      * @param ctx context
@@ -295,15 +313,6 @@
     public boolean updateChange(ChangeContext ctx) throws Exception {
       return false;
     }
-
-    /**
-     * Override this method to perform operations after the update.
-     *
-     * @param ctx context
-     */
-    // TODO(dborowitz): Support async operations?
-    public void postUpdate(Context ctx) throws Exception {
-    }
   }
 
   public abstract static class InsertChangeOp extends Op {
@@ -317,7 +326,7 @@
    * methods are called after that phase has been completed for <em>all</em> updates.
    */
   public static class Listener {
-    private static final Listener NONE = new Listener();
+    public static final Listener NONE = new Listener();
 
     /**
      * Called after updating all repositories and flushing objects but before
@@ -463,6 +472,7 @@
   private final ReviewDb db;
   private final SchemaFactory<ReviewDb> schemaFactory;
 
+  private final long logThresholdNanos;
   private final Project.NameKey project;
   private final CurrentUser user;
   private final Timestamp when;
@@ -473,6 +483,7 @@
   private final Map<Change.Id, Change> newChanges = new HashMap<>();
   private final List<CheckedFuture<?, IOException>> indexFutures =
       new ArrayList<>();
+  private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
 
   private Repository repo;
   private ObjectInserter inserter;
@@ -485,6 +496,7 @@
 
   @AssistedInject
   BatchUpdate(
+      @GerritServerConfig Config cfg,
       AllUsersName allUsers,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
@@ -513,6 +525,10 @@
     this.schemaFactory = schemaFactory;
     this.updateManagerFactory = updateManagerFactory;
 
+    this.logThresholdNanos = MILLISECONDS.toNanos(
+        ConfigUtil.getTimeUnit(
+            cfg, "change", null, "updateDebugLogThreshold",
+            SECONDS.toMillis(2), MILLISECONDS));
     this.db = db;
     this.project = project;
     this.user = user;
@@ -585,10 +601,17 @@
 
   public BatchUpdate addOp(Change.Id id, Op op) {
     checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
+    checkNotNull(op);
     ops.put(id, op);
     return this;
   }
 
+  public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
+    checkArgument(!(op instanceof Op), "use addOp()");
+    repoOnlyOps.add(op);
+    return this;
+  }
+
   public BatchUpdate insertChange(InsertChangeOp op) {
     Context ctx = new Context();
     Change c = op.createChange(ctx);
@@ -614,6 +637,11 @@
       for (Op op : ops.values()) {
         op.updateRepo(ctx);
       }
+
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
       if (inserter != null) {
         inserter.flush();
       }
@@ -669,10 +697,14 @@
         tasks.add(task);
         futures.add(executor.submit(task));
       }
+      long startNanos = System.nanoTime();
       Futures.allAsList(futures).get();
+      maybeLogSlowUpdate(startNanos, "change");
 
       if (notesMigration.commitChangeWrites()) {
+        startNanos = System.nanoTime();
         executeNoteDbUpdates(tasks);
+        maybeLogSlowUpdate(startNanos, "NoteDb");
       }
     } catch (ExecutionException | InterruptedException e) {
       Throwables.propagateIfInstanceOf(e.getCause(), UpdateException.class);
@@ -692,6 +724,25 @@
     }
   }
 
+  private static class SlowUpdateException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    private SlowUpdateException(String fmt, Object... args) {
+      super(String.format(fmt, args));
+    }
+  }
+
+  private void maybeLogSlowUpdate(long startNanos, String desc) {
+    long elapsedNanos = System.nanoTime() - startNanos;
+    if (!log.isDebugEnabled() || elapsedNanos <= logThresholdNanos) {
+      return;
+    }
+    log.debug("Slow " + desc + " update",
+        new SlowUpdateException(
+            "Slow %s update (%d ms) to %s for %s",
+            desc, NANOSECONDS.toMillis(elapsedNanos), project, ops.keySet()));
+  }
+
   private void executeNoteDbUpdates(List<ChangeTask> tasks) {
     // Aggregate together all NoteDb ref updates from the ops we executed,
     // possibly in parallel. Each task had its own NoteDbUpdateManager instance
@@ -928,5 +979,9 @@
     for (Op op : ops.values()) {
       op.postUpdate(ctx);
     }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index da9cf1d..093d036b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -403,7 +403,8 @@
     private boolean isRepo(Path p) {
       String name = p.getFileName().toString();
       return !name.equals(Constants.DOT_GIT)
-          && name.endsWith(Constants.DOT_GIT_EXT);
+          && (name.endsWith(Constants.DOT_GIT_EXT)
+              || FileKey.isGitRepository(p.toFile(), FS.DETECTED));
     }
 
     private void addProject(Path p) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
index 0974aed..c69abf5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
@@ -731,9 +731,9 @@
 
   private void updateSuperProjects(Collection<Branch.NameKey> branches) {
     logDebug("Updating superprojects");
-    SubmoduleOp subOp = subOpFactory.create(orm);
+    SubmoduleOp subOp = subOpFactory.create(branches, orm);
     try {
-      subOp.updateSuperProjects(branches);
+      subOp.updateSuperProjects();
       logDebug("Updating superprojects done");
     } catch (SubmoduleException e) {
       logError("The gitlinks were not updated according to the "
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
index 06ef492..2bfe5b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
@@ -38,7 +38,10 @@
 
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 
@@ -99,6 +102,14 @@
       return update;
     }
 
+    /**
+     * Make sure the update has already executed before reset it.
+     * TODO:czhen Have a flag in BatchUpdate to mark if it has been executed
+     */
+    void resetUpdate() {
+      update = null;
+    }
+
     void close() {
       if (update != null) {
         update.close();
@@ -197,6 +208,14 @@
     }
   }
 
+  public List<BatchUpdate> batchUpdates(Collection<Project.NameKey> projects) {
+    List<BatchUpdate> updates = new ArrayList<>(projects.size());
+    for (Project.NameKey project : projects) {
+      updates.add(getRepo(project).getUpdate());
+    }
+    return updates;
+  }
+
   @Override
   public void close() {
     for (OpenRepo repo : openRepos.values()) {
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 fa9fe2d..ff8a12a 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
@@ -646,8 +646,8 @@
     // Update superproject gitlinks if required.
     try (MergeOpRepoManager orm = ormProvider.get()) {
       orm.setContext(db, TimeUtil.nowTs(), user, "receiveID");
-      SubmoduleOp op = subOpFactory.create(orm);
-      op.updateSuperProjects(branches);
+      SubmoduleOp op = subOpFactory.create(branches, orm);
+      op.updateSuperProjects();
     } catch (SubmoduleException e) {
       log.error("Can't update the superprojects", e);
     }
@@ -1799,6 +1799,7 @@
             ApprovalsUtil.renderMessageWithApprovals(
                 psId.get(), approvals,
                 Collections.<String, PatchSetApproval> emptyMap()));
+        msg.append('.');
         if (!Strings.isNullOrEmpty(magicBranch.message)) {
           msg.append("\n").append(magicBranch.message);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index b012d4d..2e162b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -263,10 +263,12 @@
 
     String approvalMessage = ApprovalsUtil.renderMessageWithApprovals(
         patchSetId.get(), approvals, scanLabels(ctx, approvals));
-    StringBuilder message = new StringBuilder(approvalMessage);
     String kindMessage = changeKindMessage(changeKind);
+    StringBuilder message = new StringBuilder(approvalMessage);
     if (!Strings.isNullOrEmpty(kindMessage)) {
       message.append(kindMessage);
+    } else {
+      message.append('.');
     }
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n").append(reviewMessage);
@@ -293,9 +295,9 @@
       case MERGE_FIRST_PARENT_UPDATE:
       case TRIVIAL_REBASE:
       case NO_CHANGE:
-        return ": Patch Set " + priorPatchSetId.get() + " was rebased";
+        return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
       case NO_CODE_CHANGE:
-        return ": Commit message was updated";
+        return ": Commit message was updated.";
       case REWORK:
       default:
         return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index edaed13..1a8c4a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -16,16 +16,18 @@
 
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.Multimap;
-import com.google.gerrit.common.Nullable;
+import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.data.SubscribeSection;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.SubmoduleSubscription;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.BatchUpdate.Listener;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -39,20 +41,17 @@
 import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
 import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
 import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.FileMode;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.transport.RefSpec;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -60,50 +59,119 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Map;
 import java.util.Set;
 
 public class SubmoduleOp {
+
+  /**
+   * Only used for branches without code review changes
+   */
+  public class GitlinkOp extends BatchUpdate.RepoOnlyOp {
+    private final Branch.NameKey branch;
+
+    GitlinkOp(Branch.NameKey branch) {
+      this.branch = branch;
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws Exception {
+      CodeReviewCommit c = composeGitlinksCommit(branch, null);
+      ctx.addRefUpdate(new ReceiveCommand(c.getParent(0), c, branch.get()));
+      addBranchTip(branch, c);
+    }
+  }
+
   public interface Factory {
-    SubmoduleOp create(MergeOpRepoManager orm);
+    SubmoduleOp create(
+        Collection<Branch.NameKey> updatedBranches, MergeOpRepoManager orm);
   }
 
   private static final Logger log = LoggerFactory.getLogger(SubmoduleOp.class);
 
   private final GitModules.Factory gitmodulesFactory;
   private final PersonIdent myIdent;
-  private final GitReferenceUpdated gitRefUpdated;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
-  private final Account account;
   private final boolean verboseSuperProject;
   private final boolean enableSuperProjectSubscriptions;
+  private final Multimap<Branch.NameKey, SubmoduleSubscription> targets;
+  private final Collection<Branch.NameKey> updatedBranches;
   private final MergeOpRepoManager orm;
+  private final Map<Branch.NameKey, CodeReviewCommit> branchTips;
+
 
   @AssistedInject
   public SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
       @GerritPersonIdent PersonIdent myIdent,
       @GerritServerConfig Config cfg,
-      GitReferenceUpdated gitRefUpdated,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
-      @Nullable Account account,
-      @Assisted MergeOpRepoManager orm) {
+      @Assisted Collection<Branch.NameKey> updatedBranches,
+      @Assisted MergeOpRepoManager orm) throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
-    this.gitRefUpdated = gitRefUpdated;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
-    this.account = account;
     this.verboseSuperProject = cfg.getBoolean("submodule",
         "verboseSuperprojectUpdate", true);
     this.enableSuperProjectSubscriptions = cfg.getBoolean("submodule",
         "enableSuperProjectSubscriptions", true);
     this.orm = orm;
+    this.updatedBranches = updatedBranches;
+    this.targets = HashMultimap.create();
+    this.branchTips = new HashMap<>();
+    calculateSubscriptionMap();
   }
 
-  public Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src,
+  private void calculateSubscriptionMap() throws SubmoduleException {
+    if (!enableSuperProjectSubscriptions) {
+      logDebug("Updating superprojects disabled");
+      return;
+    }
+
+    logDebug("Calculating superprojects - submodules map");
+    for (Branch.NameKey updatedBranch : updatedBranches) {
+      logDebug("Now processing " + updatedBranch);
+      Set<Branch.NameKey> checkedTargets = new HashSet<>();
+      Set<Branch.NameKey> targetsToProcess = new HashSet<>();
+      targetsToProcess.add(updatedBranch);
+
+      while (!targetsToProcess.isEmpty()) {
+        Set<Branch.NameKey> newTargets = new HashSet<>();
+        for (Branch.NameKey b : targetsToProcess) {
+          try {
+            Collection<SubmoduleSubscription> subs =
+                superProjectSubscriptionsForSubmoduleBranch(b);
+            for (SubmoduleSubscription sub : subs) {
+              Branch.NameKey dst = sub.getSuperProject();
+              targets.put(dst, sub);
+              newTargets.add(dst);
+            }
+          } catch (IOException e) {
+            throw new SubmoduleException("Cannot find superprojects for " + b, e);
+          }
+        }
+        logDebug("adding to done " + targetsToProcess);
+        checkedTargets.addAll(targetsToProcess);
+        logDebug("completely done with " + checkedTargets);
+
+        Set<Branch.NameKey> intersection = new HashSet<>(checkedTargets);
+        intersection.retainAll(newTargets);
+        if (!intersection.isEmpty()) {
+          throw new SubmoduleException(
+              "Possible circular subscription involving " + updatedBranch);
+        }
+
+        targetsToProcess = newTargets;
+      }
+    }
+  }
+
+  private Collection<Branch.NameKey> getDestinationBranches(Branch.NameKey src,
       SubscribeSection s) throws IOException {
     Collection<Branch.NameKey> ret = new ArrayList<>();
     logDebug("Inspecting SubscribeSection " + s);
@@ -179,72 +247,34 @@
     return ret;
   }
 
-  protected void updateSuperProjects(Collection<Branch.NameKey> updatedBranches)
-      throws SubmoduleException {
-    if (!enableSuperProjectSubscriptions) {
-      logDebug("Updating superprojects disabled");
-      return;
-    }
-    logDebug("Updating superprojects");
-
-    Multimap<Branch.NameKey, SubmoduleSubscription> targets =
-        HashMultimap.create();
-
-    for (Branch.NameKey updatedBranch : updatedBranches) {
-      logDebug("Now processing " + updatedBranch);
-      Set<Branch.NameKey> checkedTargets = new HashSet<>();
-      Set<Branch.NameKey> targetsToProcess = new HashSet<>();
-      targetsToProcess.add(updatedBranch);
-
-      while (!targetsToProcess.isEmpty()) {
-        Set<Branch.NameKey> newTargets = new HashSet<>();
-        for (Branch.NameKey b : targetsToProcess) {
-          try {
-            Collection<SubmoduleSubscription> subs =
-                superProjectSubscriptionsForSubmoduleBranch(b);
-            for (SubmoduleSubscription sub : subs) {
-              Branch.NameKey dst = sub.getSuperProject();
-              targets.put(dst, sub);
-              newTargets.add(dst);
-            }
-          } catch (IOException e) {
-            throw new SubmoduleException("Cannot find superprojects for " + b, e);
-          }
+  public void updateSuperProjects() throws SubmoduleException {
+    SetMultimap<Project.NameKey, Branch.NameKey> dst = branchesByProject();
+    Set<Project.NameKey> projects = dst.keySet();
+    try {
+      for (Project.NameKey project : projects) {
+        // get a new BatchUpdate for the project
+        orm.openRepo(project, false);
+        //TODO:czhen remove this when MergeOp combine this into BatchUpdate
+        orm.getRepo(project).resetUpdate();
+        for (Branch.NameKey branch : dst.get(project)) {
+          SubmoduleOp.GitlinkOp op = new SubmoduleOp.GitlinkOp(branch);
+          orm.getRepo(project).getUpdate().addRepoOnlyOp(op);
         }
-        logDebug("adding to done " + targetsToProcess);
-        checkedTargets.addAll(targetsToProcess);
-        logDebug("completely done with " + checkedTargets);
-
-        Set<Branch.NameKey> intersection = new HashSet<>(checkedTargets);
-        intersection.retainAll(newTargets);
-        if (!intersection.isEmpty()) {
-          throw new SubmoduleException(
-              "Possible circular subscription involving " + updatedBranch);
-        }
-
-        targetsToProcess = newTargets;
       }
-    }
-
-    for (Branch.NameKey dst : targets.keySet()) {
-      try {
-        updateGitlinks(dst, targets.get(dst));
-      } catch (SubmoduleException e) {
-        throw new SubmoduleException("Cannot update gitlinks for " + dst, e);
-      }
+      BatchUpdate.execute(orm.batchUpdates(projects), Listener.NONE);
+    } catch (RestApiException | UpdateException | IOException |
+        NoSuchProjectException e) {
+      throw new SubmoduleException("Cannot update gitlinks", e);
     }
   }
 
   /**
-   * Update the submodules in one branch of one repository.
-   *
-   * @param subscriber the branch of the repository which should be changed.
-   * @param updates submodule updates which should be updated to.
-   * @throws SubmoduleException
+   * Create a gitlink update commit on the tip of subscriber or modify the
+   * baseCommit with gitlink update patch
    */
-  private void updateGitlinks(Branch.NameKey subscriber,
-      Collection<SubmoduleSubscription> updates)
-          throws SubmoduleException {
+  public CodeReviewCommit composeGitlinksCommit(
+      final Branch.NameKey subscriber, RevCommit baseCommit)
+      throws IOException, SubmoduleException {
     PersonIdent author = null;
     StringBuilder msgbuf = new StringBuilder("Update git submodules\n\n");
     boolean sameAuthorForAll = true;
@@ -254,150 +284,147 @@
     } catch (NoSuchProjectException | IOException e) {
       throw new SubmoduleException("Cannot access superproject", e);
     }
+
     OpenRepo or = orm.getRepo(subscriber.getParentKey());
-    try {
-      Ref r = or.repo.exactRef(subscriber.get());
-      if (r == null) {
-        throw new SubmoduleException(
-            "The branch was probably deleted from the subscriber repository");
-      }
-
-      DirCache dc = readTree(r, or.rw);
-      DirCacheEditor ed = dc.editor();
-
-      for (SubmoduleSubscription s : updates) {
-        try {
-          orm.openRepo(s.getSubmodule().getParentKey(), false);
-        } catch (NoSuchProjectException | IOException e) {
-          throw new SubmoduleException("Cannot access submodule", e);
-        }
-        OpenRepo subOr = orm.getRepo(s.getSubmodule().getParentKey());
-        Repository subrepo = subOr.repo;
-
-        Ref ref = subrepo.getRefDatabase().exactRef(s.getSubmodule().get());
-        if (ref == null) {
-          ed.add(new DeletePath(s.getPath()));
-          continue;
-        }
-
-        final ObjectId updateTo = ref.getObjectId();
-        RevCommit newCommit = subOr.rw.parseCommit(updateTo);
-
-        subOr.rw.parseBody(newCommit);
-        if (author == null) {
-          author = newCommit.getAuthorIdent();
-        } else if (!author.equals(newCommit.getAuthorIdent())) {
-          sameAuthorForAll = false;
-        }
-
-        DirCacheEntry dce = dc.getEntry(s.getPath());
-        ObjectId oldId;
-        if (dce != null) {
-          if (!dce.getFileMode().equals(FileMode.GITLINK)) {
-            log.error("Requested to update gitlink " + s.getPath() + " in "
-                + s.getSubmodule().getParentKey().get() + " but entry "
-                + "doesn't have gitlink file mode.");
-            continue;
-          }
-          oldId = dce.getObjectId();
-        } else {
-          // This submodule did not exist before. We do not want to add
-          // the full submodule history to the commit message, so omit it.
-          oldId = updateTo;
-        }
-
-        ed.add(new PathEdit(s.getPath()) {
-          @Override
-          public void apply(DirCacheEntry ent) {
-            ent.setFileMode(FileMode.GITLINK);
-            ent.setObjectId(updateTo);
-          }
-        });
-        if (verboseSuperProject) {
-          msgbuf.append("Project: " + s.getSubmodule().getParentKey().get());
-          msgbuf.append(" " + s.getSubmodule().getShortName());
-          msgbuf.append(" " + updateTo.getName());
-          msgbuf.append("\n\n");
-
-          try {
-            subOr.rw.resetRetain(subOr.canMergeFlag);
-            subOr.rw.markStart(newCommit);
-            subOr.rw.markUninteresting(subOr.rw.parseCommit(oldId));
-            for (RevCommit c : subOr.rw) {
-              subOr.rw.parseBody(c);
-              msgbuf.append(c.getFullMessage() + "\n\n");
-            }
-          } catch (IOException e) {
-            throw new SubmoduleException("Could not perform a revwalk to "
-                + "create superproject commit message", e);
-          }
-        }
-      }
-      ed.finish();
-
-      if (!sameAuthorForAll || author == null) {
-        author = myIdent;
-      }
-
-      ObjectInserter oi = or.repo.newObjectInserter();
-      ObjectId tree = dc.writeTree(oi);
-
-      ObjectId currentCommitId =
-          or.repo.exactRef(subscriber.get()).getObjectId();
-
-      CommitBuilder commit = new CommitBuilder();
-      commit.setTreeId(tree);
-      commit.setParentIds(new ObjectId[] {currentCommitId});
-      commit.setAuthor(author);
-      commit.setCommitter(myIdent);
-      commit.setMessage(msgbuf.toString());
-      oi.insert(commit);
-      oi.flush();
-
-      ObjectId commitId = oi.idFor(Constants.OBJ_COMMIT, commit.build());
-
-      final RefUpdate rfu = or.repo.updateRef(subscriber.get());
-      rfu.setForceUpdate(false);
-      rfu.setNewObjectId(commitId);
-      rfu.setExpectedOldObjectId(currentCommitId);
-      rfu.setRefLogMessage("Submit to " + subscriber.getParentKey().get(), true);
-
-      switch (rfu.update()) {
-        case NEW:
-        case FAST_FORWARD:
-          gitRefUpdated.fire(subscriber.getParentKey(), rfu, account);
-          // TODO since this is performed "in the background" no mail will be
-          // sent to inform users about the updated branch
-          break;
-        case FORCED:
-        case IO_FAILURE:
-        case LOCK_FAILURE:
-        case NOT_ATTEMPTED:
-        case NO_CHANGE:
-        case REJECTED:
-        case REJECTED_CURRENT_BRANCH:
-        case RENAMED:
-        default:
-          throw new IOException(rfu.getResult().name());
-      }
-    } catch (IOException e) {
-      throw new SubmoduleException("Cannot update gitlinks for "
-          + subscriber.get(), e);
+    Ref r = or.repo.exactRef(subscriber.get());
+    if (r == null) {
+      throw new SubmoduleException(
+          "The branch was probably deleted from the subscriber repository");
     }
+
+    RevCommit currentCommit = (baseCommit != null) ? baseCommit :
+        or.rw.parseCommit(or.repo.exactRef(subscriber.get()).getObjectId());
+    or.rw.parseBody(currentCommit);
+
+    DirCache dc = readTree(or.rw, currentCommit);
+    DirCacheEditor ed = dc.editor();
+
+    for (SubmoduleSubscription s : targets.get(subscriber)) {
+      try {
+        orm.openRepo(s.getSubmodule().getParentKey(), false);
+      } catch (NoSuchProjectException | IOException e) {
+        throw new SubmoduleException("Cannot access submodule", e);
+      }
+      OpenRepo subOr = orm.getRepo(s.getSubmodule().getParentKey());
+      Repository subRepo = subOr.repo;
+
+      Ref ref = subRepo.getRefDatabase().exactRef(s.getSubmodule().get());
+      if (ref == null) {
+        ed.add(new DeletePath(s.getPath()));
+        continue;
+      }
+
+      ObjectId updateTo = ref.getObjectId();
+      if (branchTips.containsKey(s.getSubmodule())) {
+        updateTo = branchTips.get(s.getSubmodule());
+      }
+      RevWalk subOrRw = subOr.rw;
+      final RevCommit newCommit = subOrRw.parseCommit(updateTo);
+
+      subOrRw.parseBody(newCommit);
+      if (author == null) {
+        author = newCommit.getAuthorIdent();
+      } else if (!author.equals(newCommit.getAuthorIdent())) {
+        sameAuthorForAll = false;
+      }
+
+      DirCacheEntry dce = dc.getEntry(s.getPath());
+      ObjectId oldId;
+      if (dce != null) {
+        if (!dce.getFileMode().equals(FileMode.GITLINK)) {
+          String errMsg = "Requested to update gitlink " + s.getPath() + " in "
+              + s.getSubmodule().getParentKey().get() + " but entry "
+              + "doesn't have gitlink file mode.";
+          throw new SubmoduleException(errMsg);
+        }
+        oldId = dce.getObjectId();
+      } else {
+        // This submodule did not exist before. We do not want to add
+        // the full submodule history to the commit message, so omit it.
+        oldId = updateTo;
+      }
+
+      ed.add(new PathEdit(s.getPath()) {
+        @Override
+        public void apply(DirCacheEntry ent) {
+          ent.setFileMode(FileMode.GITLINK);
+          ent.setObjectId(newCommit.getId());
+        }
+      });
+      if (verboseSuperProject) {
+        msgbuf.append("Project: " + s.getSubmodule().getParentKey().get());
+        msgbuf.append(" " + s.getSubmodule().getShortName());
+        msgbuf.append(" " + newCommit.getName());
+        msgbuf.append("\n\n");
+
+        try {
+          subOrRw.resetRetain(subOr.canMergeFlag);
+          subOrRw.markStart(newCommit);
+          subOrRw.markUninteresting(subOrRw.parseCommit(oldId));
+          for (RevCommit c : subOrRw) {
+            subOrRw.parseBody(c);
+            msgbuf.append(c.getFullMessage() + "\n\n");
+          }
+        } catch (IOException e) {
+          throw new SubmoduleException("Could not perform a revwalk to "
+              + "create superproject commit message", e);
+        }
+      }
+    }
+    ed.finish();
+
+
+    ObjectInserter oi = or.ins;
+    CodeReviewRevWalk rw = or.rw;
+    ObjectId tree = dc.writeTree(oi);
+
+    if (!sameAuthorForAll || author == null) {
+      author = myIdent;
+    }
+
+    CommitBuilder commit = new CommitBuilder();
+    commit.setTreeId(tree);
+    if (baseCommit != null) {
+      // modify the baseCommit
+      commit.setParentIds(baseCommit.getParents());
+      commit.setMessage(baseCommit.getFullMessage() + "\n\n" + msgbuf.toString());
+      commit.setAuthor(baseCommit.getAuthorIdent());
+    } else {
+      // create a new commit
+      commit.setParentId(currentCommit);
+      commit.setMessage(msgbuf.toString());
+      commit.setAuthor(author);
+    }
+    commit.setCommitter(myIdent);
+
+    ObjectId id = oi.insert(commit);
+    return rw.parseCommit(id);
   }
 
-  private static DirCache readTree(final Ref branch, RevWalk rw)
-      throws MissingObjectException, IncorrectObjectTypeException,
-      IOException {
+  private static DirCache readTree(RevWalk rw, ObjectId base)
+      throws IOException {
     final DirCache dc = DirCache.newInCore();
     final DirCacheBuilder b = dc.builder();
     b.addTree(new byte[0], // no prefix path
         DirCacheEntry.STAGE_0, // standard stage
-        rw.getObjectReader(), rw.parseTree(branch.getObjectId()));
+        rw.getObjectReader(), rw.parseTree(base));
     b.finish();
     return dc;
   }
 
+  public SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject() {
+    SetMultimap<Project.NameKey, Branch.NameKey> ret = HashMultimap.create();
+    for (Branch.NameKey branch : targets.keySet()) {
+      ret.put(branch.getParentKey(), branch);
+    }
+
+    return ret;
+  }
+
+  public void addBranchTip(Branch.NameKey branch, CodeReviewCommit tip) {
+    branchTips.put(branch, tip);
+  }
+
   private void logDebug(String msg, Object... args) {
     if (log.isDebugEnabled()) {
       log.debug("[" + orm.getSubmissionId() + "]" + msg, args);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index eb4d400..6334cd2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -78,7 +78,7 @@
     }
   }
 
-  private RevCommit revision;
+  protected RevCommit revision;
   protected ObjectReader reader;
   protected ObjectInserter inserter;
   protected DirCache newTree;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index f9bad6f..4c1a734 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -82,6 +82,7 @@
   private static final String FILE = "File";
   private static final String LENGTH = "Bytes";
   private static final String PARENT = "Parent";
+  private static final String PARENT_NUMBER = "Parent-number";
   private static final String PATCH_SET = "Patch-set";
   private static final String REVISION = "Revision";
   private static final String UUID = "UUID";
@@ -151,11 +152,13 @@
     int sizeOfNote = note.length;
     byte[] psb = PATCH_SET.getBytes(UTF_8);
     byte[] bpsb = BASE_PATCH_SET.getBytes(UTF_8);
+    byte[] bpn = PARENT_NUMBER.getBytes(UTF_8);
 
     RevId revId = new RevId(parseStringField(note, p, changeId, REVISION));
     String fileName = null;
     PatchSet.Id psId = null;
     boolean isForBase = false;
+    Integer parentNumber = null;
 
     while (p.value < sizeOfNote) {
       boolean matchPs = match(note, p, psb);
@@ -168,13 +171,16 @@
         fileName = null;
         psId = parsePsId(note, p, changeId, BASE_PATCH_SET);
         isForBase = true;
+        if (match(note, p, bpn)) {
+          parentNumber = parseParentNumber(note, p, changeId);
+        }
       } else if (psId == null) {
         throw parseException(changeId, "missing %s or %s header",
             PATCH_SET, BASE_PATCH_SET);
       }
 
-      PatchLineComment c =
-          parseComment(note, p, fileName, psId, revId, isForBase, status);
+      PatchLineComment c = parseComment(
+          note, p, fileName, psId, revId, isForBase, parentNumber, status);
       fileName = c.getKey().getParentKey().getFileName();
       if (!seen.add(c.getKey())) {
         throw parseException(
@@ -187,7 +193,7 @@
 
   private PatchLineComment parseComment(byte[] note, MutableInteger curr,
       String currentFileName, PatchSet.Id psId, RevId revId, boolean isForBase,
-      Status status) throws ConfigInvalidException {
+      Integer parentNumber, Status status) throws ConfigInvalidException {
     Change.Id changeId = psId.getParentKey();
 
     // Check if there is a new file.
@@ -235,7 +241,13 @@
         range.getEndLine(), aId, parentUUID, commentTime);
     plc.setMessage(message);
     plc.setTag(tag);
-    plc.setSide((short) (isForBase ? 0 : 1));
+
+    if (isForBase) {
+      plc.setSide((short) (parentNumber == null ? 0 : -parentNumber));
+    } else {
+      plc.setSide((short) 1);
+    }
+
     if (range.getStartCharacter() != -1) {
       plc.setRange(range);
     }
@@ -333,6 +345,23 @@
     return new PatchSet.Id(changeId, patchSetId);
   }
 
+  private static Integer parseParentNumber(byte[] note, MutableInteger curr,
+      Change.Id changeId) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, PARENT_NUMBER, changeId);
+
+    int start = RawParseUtils.endOfFooterLineKey(note, curr.value) + 1;
+    MutableInteger i = new MutableInteger();
+    int parentNumber = RawParseUtils.parseBase10(note, start, i);
+    int endOfLine = RawParseUtils.nextLF(note, curr.value);
+    if (i.value != endOfLine - 1) {
+      throw parseException(changeId, "could not parse %s", PARENT_NUMBER);
+    }
+    checkResult(parentNumber, "parent number", changeId);
+    curr.value = endOfLine;
+    return Integer.valueOf(parentNumber);
+
+  }
+
   private static String parseFilename(byte[] note, MutableInteger curr,
       Change.Id changeId) throws ConfigInvalidException {
     checkHeaderLineFormat(note, curr, FILE, changeId);
@@ -461,10 +490,13 @@
         PatchLineComment first = psComments.get(0);
 
         short side = first.getSide();
-        appendHeaderField(writer, side == 0
+        appendHeaderField(writer, side <= 0
             ? BASE_PATCH_SET
             : PATCH_SET,
             Integer.toString(psId.get()));
+        if (side < 0) {
+          appendHeaderField(writer, PARENT_NUMBER, Integer.toString(-side));
+        }
 
         String currentFilename = null;
 
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 4737ba3..cdaf08c 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
@@ -75,6 +75,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 /** View of a single {@link Change} based on the log of its notes branch. */
 public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
@@ -559,8 +560,8 @@
 
   private LoadHandle rebuildAndOpen(Repository repo, ObjectId oldId)
       throws IOException {
-    try (Timer1.Context timer =
-        args.metrics.autoRebuildLatency.start(CHANGES)) {
+    Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
+    try {
       Change.Id cid = getChangeId();
       ReviewDb db = args.db.get();
       ChangeRebuilder rebuilder = args.rebuilder.get();
@@ -583,6 +584,7 @@
           //
           // Parse notes from the staged result so we can return something useful
           // to the caller instead of throwing.
+          log.debug("Rebuilding change {} failed", getChangeId());
           args.metrics.autoRebuildFailureCount.increment(CHANGES);
           rebuildResult = checkNotNull(r);
           checkNotNull(r.newState());
@@ -599,6 +601,10 @@
       return super.openHandle(repo, oldId);
     } catch (OrmException e) {
       throw new IOException(e);
+    } finally {
+      log.debug("Rebuilt change {} in project {} in {} ms",
+          getChangeId(), getProjectName(),
+          TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 4605887..684e4f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -43,14 +43,20 @@
 import org.eclipse.jgit.notes.NoteMap;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.concurrent.TimeUnit;
 
 /**
  * View of the draft comments for a single {@link Change} based on the log of
  * its drafts branch.
  */
 public class DraftCommentNotes extends AbstractChangeNotes<DraftCommentNotes> {
+  private static final Logger log =
+      LoggerFactory.getLogger(DraftCommentNotes.class);
+
   public interface Factory {
     DraftCommentNotes create(Change change, Account.Id accountId);
     DraftCommentNotes createWithAutoRebuildingDisabled(
@@ -184,8 +190,8 @@
   }
 
   private LoadHandle rebuildAndOpen(Repository repo) throws IOException {
-    try (Timer1.Context timer =
-        args.metrics.autoRebuildLatency.start(CHANGES)) {
+    Timer1.Context timer = args.metrics.autoRebuildLatency.start(CHANGES);
+    try {
       Change.Id cid = getChangeId();
       ReviewDb db = args.db.get();
       ChangeRebuilder rebuilder = args.rebuilder.get();
@@ -200,6 +206,7 @@
           repo.scanForRepoChanges();
         } catch (OrmException | IOException e) {
           // See ChangeNotes#rebuildAndOpen.
+          log.debug("Rebuilding change {} via drafts failed", getChangeId());
           args.metrics.autoRebuildFailureCount.increment(CHANGES);
           checkNotNull(r.staged());
           return LoadHandle.create(
@@ -213,6 +220,13 @@
       return super.openHandle(repo);
     } catch (OrmException e) {
       throw new IOException(e);
+    } finally {
+      log.debug("Rebuilt change {} in {} in {} ms via drafts",
+          getChangeId(),
+          change != null
+              ? "project " + change.getProject()
+              : "unknown project",
+          TimeUnit.MILLISECONDS.convert(timer.stop(), TimeUnit.NANOSECONDS));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index 038ad51..f22d8a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
@@ -25,7 +25,7 @@
 import java.io.Serializable;
 
 public class IntraLineDiffKey implements Serializable {
-  static final long serialVersionUID = 4L;
+  public static final long serialVersionUID = 4L;
 
   private transient boolean ignoreWhitespace;
   private transient ObjectId aId;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
index 2099376..8a2403f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -28,7 +28,7 @@
   PatchList get(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException;
 
-  ObjectId getOldId(Change change, PatchSet patchSet)
+  ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
       throws PatchListNotAvailableException;
 
   IntraLineDiff getIntraLineDiff(IntraLineDiffKey key,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index 7c8f19f..abafad7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -103,6 +103,17 @@
   @Override
   public PatchList get(Change change, PatchSet patchSet)
       throws PatchListNotAvailableException {
+    return get(change, patchSet, null);
+  }
+
+  @Override
+  public ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
+      throws PatchListNotAvailableException {
+    return get(change, patchSet, parentNum).getOldId();
+  }
+
+  private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
+      throws PatchListNotAvailableException {
     Project.NameKey project = change.getProject();
     if (patchSet.getRevision() == null) {
       throw new PatchListNotAvailableException(
@@ -110,13 +121,10 @@
     }
     ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
     Whitespace ws = Whitespace.IGNORE_NONE;
-    return get(new PatchListKey(null, b, ws), project);
-  }
-
-  @Override
-  public ObjectId getOldId(Change change, PatchSet patchSet)
-      throws PatchListNotAvailableException {
-    return get(change, patchSet).getOldId();
+    if (parentNum != null) {
+      return get(PatchListKey.againstParentNum(parentNum, b, ws), project);
+    }
+    return get(PatchListKey.againstDefaultBase(b, ws), project);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
index b04558d..961ed5c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -32,9 +32,10 @@
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 import java.io.Serializable;
+import java.util.Objects;
 
 public class PatchListKey implements Serializable {
-  static final long serialVersionUID = 20L;
+  public static final long serialVersionUID = 21L;
 
   public static final BiMap<Whitespace, Character> WHITESPACE_TYPES = ImmutableBiMap.of(
       Whitespace.IGNORE_NONE, 'N',
@@ -46,7 +47,36 @@
     checkState(WHITESPACE_TYPES.size() == Whitespace.values().length);
   }
 
+  public static PatchListKey againstDefaultBase(AnyObjectId newId,
+      Whitespace ws) {
+    return new PatchListKey(null, newId, ws);
+  }
+
+  public static PatchListKey againstParentNum(int parentNum, AnyObjectId newId,
+      Whitespace ws) {
+    return new PatchListKey(parentNum, newId, ws);
+  }
+
+  /**
+   * Old patch-set ID
+   * <p>
+   * When null, it represents the Base of the newId for a non-merge commit.
+   * <p>
+   * When newId is a merge commit, null value of the oldId represents either
+   * the auto-merge commit of the newId or a parent commit of the newId.
+   * These two cases are distinguished by the parentNum.
+   */
   private transient ObjectId oldId;
+
+  /**
+   * 1-based parent number when newId is a merge commit
+   * <p>
+   * For the auto-merge case this field is null.
+   * <p>
+   * Used only when oldId is null and newId is a merge commit
+   */
+  private transient Integer parentNum;
+
   private transient ObjectId newId;
   private transient Whitespace whitespace;
 
@@ -56,12 +86,24 @@
     whitespace = ws;
   }
 
+  private PatchListKey(int parentNum, AnyObjectId b, Whitespace ws) {
+    this.parentNum = Integer.valueOf(parentNum);
+    newId = b.copy();
+    whitespace = ws;
+  }
+
   /** Old side commit, or null to assume ancestor or combined merge. */
   @Nullable
   public ObjectId getOldId() {
     return oldId;
   }
 
+  /** Parent number (old side) of the new side (merge) commit */
+  @Nullable
+  public Integer getParentNum() {
+    return parentNum;
+  }
+
   /** New side commit name. */
   public ObjectId getNewId() {
     return newId;
@@ -73,24 +115,16 @@
 
   @Override
   public int hashCode() {
-    int h = 0;
-
-    if (oldId != null) {
-      h = h * 31 + oldId.hashCode();
-    }
-
-    h = h * 31 + newId.hashCode();
-    h = h * 31 + whitespace.name().hashCode();
-
-    return h;
+    return Objects.hash(oldId, parentNum, newId, whitespace);
   }
 
   @Override
   public boolean equals(final Object o) {
     if (o instanceof PatchListKey) {
-      final PatchListKey k = (PatchListKey) o;
-      return eq(oldId, k.oldId) //
-          && eq(newId, k.newId) //
+      PatchListKey k = (PatchListKey) o;
+      return Objects.equals(oldId, k.oldId)
+          && Objects.equals(parentNum, k.parentNum)
+          && Objects.equals(newId, k.newId)
           && whitespace == k.whitespace;
     }
     return false;
@@ -109,15 +143,9 @@
     return n.toString();
   }
 
-  private static boolean eq(final ObjectId a, final ObjectId b) {
-    if (a == null && b == null) {
-      return true;
-    }
-    return a != null && b != null && AnyObjectId.equals(a, b);
-  }
-
   private void writeObject(final ObjectOutputStream out) throws IOException {
     writeCanBeNull(out, oldId);
+    out.writeInt(parentNum == null ? 0 : parentNum);
     writeNotNull(out, newId);
     Character c = WHITESPACE_TYPES.get(whitespace);
     if (c == null) {
@@ -128,6 +156,8 @@
 
   private void readObject(final ObjectInputStream in) throws IOException {
     oldId = readCanBeNull(in);
+    int n = in.readInt();
+    parentNum = n == 0 ? null : Integer.valueOf(n);
     newId = readNotNull(in);
     char t = in.readChar();
     whitespace = WHITESPACE_TYPES.inverse().get(t);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index 90bebfc..0b2a7ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -151,7 +151,7 @@
       }
 
       final boolean againstParent =
-          b.getParentCount() > 0 && b.getParent(0) == a;
+          b.getParentCount() > 0 && b.getParent(0).equals(a);
 
       RevCommit aCommit = a instanceof RevCommit ? (RevCommit) a : null;
       RevTree aTree = rw.parseTree(a);
@@ -163,11 +163,11 @@
       List<DiffEntry> diffEntries = df.scan(aTree, bTree);
 
       Set<String> paths = null;
-      if (key.getOldId() != null) {
-        PatchListKey newKey =
-            new PatchListKey(null, key.getNewId(), key.getWhitespace());
-        PatchListKey oldKey =
-            new PatchListKey(null, key.getOldId(), key.getWhitespace());
+      if (key.getOldId() != null && b.getParentCount() == 1) {
+        PatchListKey newKey = PatchListKey.againstDefaultBase(
+            key.getNewId(), key.getWhitespace());
+        PatchListKey oldKey = PatchListKey.againstDefaultBase(
+            key.getOldId(), key.getWhitespace());
         paths = FluentIterable
             .from(patchListCache.get(newKey, project).getPatches())
             .append(patchListCache.get(oldKey, project).getPatches())
@@ -331,6 +331,11 @@
         return r;
       }
       case 2:
+        if (key.getParentNum() != null) {
+          RevCommit r = b.getParent(key.getParentNum() - 1);
+          rw.parseBody(r);
+          return r;
+        }
         return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
       default:
         // TODO(sop) handle an octopus merge.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index b7ca69d4..a7d2523 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.util.GitUtil.getParent;
 
 import com.google.common.base.Optional;
 import com.google.gerrit.common.Nullable;
@@ -44,9 +45,9 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -70,6 +71,13 @@
         @Assisted("patchSetA") PatchSet.Id patchSetA,
         @Assisted("patchSetB") PatchSet.Id patchSetB,
         DiffPreferencesInfo diffPrefs);
+
+    PatchScriptFactory create(
+        ChangeControl control,
+        String fileName,
+        int parentNum,
+        PatchSet.Id patchSetB,
+        DiffPreferencesInfo diffPrefs);
   }
 
   private static final Logger log =
@@ -86,6 +94,7 @@
   private final String fileName;
   @Nullable
   private final PatchSet.Id psa;
+  private final int parentNum;
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
   private final ChangeEditUtil editReader;
@@ -103,7 +112,7 @@
   private List<Patch> history;
   private CommentDetail comments;
 
-  @Inject
+  @AssistedInject
   PatchScriptFactory(GitRepositoryManager grm,
       PatchSetUtil psUtil,
       Provider<PatchScriptBuilder> builderFactory,
@@ -129,14 +138,45 @@
 
     this.fileName = fileName;
     this.psa = patchSetA;
+    this.parentNum = -1;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
 
     changeId = patchSetB.getParentKey();
-    checkArgument(
-        patchSetA == null || patchSetA.getParentKey().equals(changeId),
-        "cannot compare PatchSets from different changes: %s and %s",
-        patchSetA, patchSetB);
+  }
+
+  @AssistedInject
+  PatchScriptFactory(GitRepositoryManager grm,
+      PatchSetUtil psUtil,
+      Provider<PatchScriptBuilder> builderFactory,
+      PatchListCache patchListCache,
+      ReviewDb db,
+      AccountInfoCacheFactory.Factory aicFactory,
+      PatchLineCommentsUtil plcUtil,
+      ChangeEditUtil editReader,
+      @Assisted ChangeControl control,
+      @Assisted String fileName,
+      @Assisted int parentNum,
+      @Assisted PatchSet.Id patchSetB,
+      @Assisted DiffPreferencesInfo diffPrefs) {
+    this.repoManager = grm;
+    this.psUtil = psUtil;
+    this.builderFactory = builderFactory;
+    this.patchListCache = patchListCache;
+    this.db = db;
+    this.control = control;
+    this.aicFactory = aicFactory;
+    this.plcUtil = plcUtil;
+    this.editReader = editReader;
+
+    this.fileName = fileName;
+    this.psa = null;
+    this.parentNum = parentNum;
+    this.psb = patchSetB;
+    this.diffPrefs = diffPrefs;
+
+    changeId = patchSetB.getParentKey();
+    checkArgument(parentNum >= 0, "parentNum must be >= 0");
   }
 
   public void setLoadHistory(boolean load) {
@@ -151,7 +191,9 @@
   public PatchScript call() throws OrmException, NoSuchChangeException,
       LargeObjectException, AuthException,
       InvalidChangeOperationException, IOException {
-    validatePatchSetId(psa);
+    if (parentNum < 0) {
+      validatePatchSetId(psa);
+    }
     validatePatchSetId(psb);
 
     change = control.getChange();
@@ -163,15 +205,19 @@
         ? new PatchSet(psb)
         : psUtil.get(db, control.getNotes(), psb);
 
-    aId = psEntityA != null ? toObjectId(psEntityA) : null;
-    bId = toObjectId(psEntityB);
-
     if ((psEntityA != null && !control.isPatchVisible(psEntityA, db)) ||
         (psEntityB != null && !control.isPatchVisible(psEntityB, db))) {
       throw new NoSuchChangeException(changeId);
     }
 
     try (Repository git = repoManager.openRepository(project)) {
+      bId = toObjectId(psEntityB);
+      if (parentNum < 0) {
+        aId = psEntityA != null ? toObjectId(psEntityA) : null;
+      } else {
+        aId = getParent(git, bId, parentNum);
+      }
+
       try {
         final PatchList list = listFor(keyFor(diffPrefs.ignoreWhitespace));
         final PatchScriptBuilder b = newBuilder(list, git);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
index 4e8e98e..fa385f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
@@ -227,7 +227,7 @@
     return Response.created(json.format(p));
   }
 
-  public Project createProject(CreateProjectArgs args)
+  private Project createProject(CreateProjectArgs args)
       throws BadRequestException, ResourceConflictException, IOException,
       ConfigInvalidException {
     final Project.NameKey nameKey = args.getProject();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index 6a21e89..b311b8f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -610,19 +610,6 @@
 
     rules = relevant.getPermission(permissionName);
 
-    if (rules.isEmpty()) {
-      effective.put(permissionName, rules);
-      return rules;
-    }
-
-    if (rules.size() == 1) {
-      if (!projectControl.match(rules.get(0), isChangeOwner)) {
-        rules = Collections.emptyList();
-      }
-      effective.put(permissionName, rules);
-      return rules;
-    }
-
     List<PermissionRule> mine = new ArrayList<>(rules.size());
     for (PermissionRule rule : rules) {
       if (projectControl.match(rule, isChangeOwner)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
new file mode 100644
index 0000000..2d1e1fa
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/GitUtil.java
@@ -0,0 +1,51 @@
+// 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.util;
+
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+
+public class GitUtil {
+
+  /**
+   * @param git
+   * @param commitId
+   * @param parentNum
+   * @return the {@code paretNo} parent of given commit or {@code null}
+   *             when {@code parentNo} exceed number of {@code commitId} parents.
+   * @throws IncorrectObjectTypeException
+   *             the supplied id is not a commit or an annotated tag.
+   * @throws IOException
+   *             a pack file or loose object could not be read.
+   */
+  public static RevCommit getParent(Repository git,
+      ObjectId commitId, int parentNum) throws IOException {
+    try (RevWalk walk = new RevWalk(git)) {
+      RevCommit commit = walk.parseCommit(commitId);
+      if (commit.getParentCount() > parentNum) {
+        return commit.getParent(parentNum);
+      }
+    }
+    return null;
+  }
+
+  private GitUtil() {
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java
new file mode 100644
index 0000000..87bfa00
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/BatchUpdateTest.java
@@ -0,0 +1,134 @@
+package com.google.gerrit.server.git;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+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.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.BatchUpdate.RepoOnlyOp;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class BatchUpdateTest {
+  @Inject
+  private AccountManager accountManager;
+
+  @Inject
+  private IdentifiedUser.GenericFactory userFactory;
+
+  @Inject
+  private InMemoryDatabase schemaFactory;
+
+  @Inject
+  private InMemoryRepositoryManager repoManager;
+
+  @Inject
+  private SchemaCreator schemaCreator;
+
+  @Inject
+  private ThreadLocalRequestContext requestContext;
+
+  @Inject
+  private BatchUpdate.Factory batchUpdateFactory;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private TestRepository<InMemoryRepository> repo;
+  private Project.NameKey project;
+  private IdentifiedUser user;
+
+  @Before
+  public void setUp() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user"))
+        .getAccountId();
+    user = userFactory.create(userId);
+
+    project = new Project.NameKey("test");
+
+    InMemoryRepository inMemoryRepo = repoManager.createRepository(project);
+    repo = new TestRepository<>(inMemoryRepo);
+
+    requestContext.setContext(new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return user;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    });
+  }
+
+  @After
+  public void tearDown() {
+    if (repo != null) {
+      repo.getRepository().close();
+    }
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void addRefUpdateFromFastForwardCommit() throws Exception {
+    final RevCommit masterCommit = repo.branch("master").commit().create();
+    final RevCommit branchCommit =
+        repo.branch("branch").commit().parent(masterCommit).create();
+
+    try (BatchUpdate bu = batchUpdateFactory
+        .create(db, project, user, TimeUtil.nowTs())) {
+      bu.addRepoOnlyOp(new RepoOnlyOp() {
+        @Override
+        public void updateRepo(RepoContext ctx) throws Exception {
+          ctx.addRefUpdate(
+              new ReceiveCommand(masterCommit.getId(), branchCommit.getId(),
+                  "refs/heads/master"));
+        }
+      });
+      bu.execute();
+    }
+
+    assertEquals(
+        repo.getRepository().exactRef("refs/heads/master").getObjectId(),
+        branchCommit.getId());
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
new file mode 100644
index 0000000..9327514
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
@@ -0,0 +1,41 @@
+<!--
+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">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-account-entry">
+  <template>
+    <style>
+      gr-autocomplete {
+        display: inline-block;
+        overflow: hidden;
+      }
+    </style>
+    <gr-autocomplete
+        id="input"
+        borderless="[[borderless]]"
+        placeholder="[[placeholder]]"
+        threshold="[[suggestFrom]]"
+        query="[[query]]"
+        on-commit="_handleInputCommit"
+        clear-on-commit>
+    </gr-autocomplete>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-account-entry.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
new file mode 100644
index 0000000..23f0b13
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -0,0 +1,126 @@
+// 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';
+
+  Polymer({
+    is: 'gr-account-entry',
+
+    /**
+     * Fired when an account is entered.
+     *
+     * @event add
+     */
+
+    properties: {
+      borderless: Boolean,
+      change: Object,
+      placeholder: String,
+
+      suggestFrom: {
+        type: Number,
+        value: 3,
+      },
+
+      filter: {
+        type: Function,
+        value: function() {
+          return this.notOwnerOrReviewer.bind(this);
+        },
+      },
+
+      query: {
+        type: Function,
+        value: function() {
+          return this._getReviewerSuggestions.bind(this);
+        },
+      },
+
+      _reviewers: {
+        type: Array,
+        value: function() { return []; },
+      },
+    },
+
+    observers: [
+      '_reviewersChanged(change.reviewers.*, change.owner)',
+    ],
+
+    get focusStart() {
+      return this.$.input.focusStart;
+    },
+
+    focus: function() {
+      this.$.input.focus();
+    },
+
+    clear: function() {
+      this.$.input.clear();
+    },
+
+    _handleInputCommit: function(e) {
+      this.fire('add', {value: e.detail.value});
+    },
+
+    _reviewersChanged: function(changeRecord, owner) {
+      var reviewerSet = {};
+      reviewerSet[owner._account_id] = true;
+      var addReviewers = function(reviewers) {
+        if (!reviewers) {
+          return;
+        }
+        reviewers.forEach(function(reviewer) {
+          reviewerSet[reviewer._account_id] = true;
+        });
+      };
+
+      var reviewers = changeRecord.base;
+      addReviewers(reviewers.CC);
+      addReviewers(reviewers.REVIEWER);
+      this._reviewers = reviewerSet;
+    },
+
+    notOwnerOrReviewer: function(reviewer) {
+      var account = reviewer.account;
+      if (!account) { return true; }
+      return !this._reviewers[reviewer.account._account_id];
+    },
+
+    _makeSuggestion: function(reviewer) {
+      if (reviewer.account) {
+        return {
+          name: reviewer.account.name + ' (' + reviewer.account.email + ')',
+          value: reviewer,
+        };
+      } else if (reviewer.group) {
+        return {
+          name: reviewer.group.name + ' (group)',
+          value: reviewer,
+        };
+      }
+    },
+
+    _getReviewerSuggestions: function(input) {
+      var xhr = this.$.restAPI.getChangeSuggestedReviewers(
+          this.change._number, input);
+
+      return xhr.then(function(reviewers) {
+        if (!reviewers) { return []; }
+        return reviewers
+            .filter(this.filter)
+            .map(this._makeSuggestion);
+      }.bind(this));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
new file mode 100644
index 0000000..f9527ca
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -0,0 +1,146 @@
+<!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-account-entry</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-account-entry.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-entry></gr-account-entry>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-entry tests', function() {
+    var _nextAccountId = 0;
+    var makeAccount = function() {
+      var accountId = ++_nextAccountId;
+      return {
+        _account_id: accountId,
+        name: 'name ' + accountId,
+        email: 'email ' + accountId,
+      };
+    };
+
+    var owner;
+    var existingReviewer1;
+    var existingReviewer2;
+    var suggestion1;
+    var suggestion2;
+    var suggestion3;
+    var element;
+
+    setup(function() {
+      owner = makeAccount();
+      existingReviewer1 = makeAccount();
+      existingReviewer2 = makeAccount();
+      suggestion1 = {account: makeAccount()};
+      suggestion2 = {account: makeAccount()};
+      suggestion3 = {
+        group: {
+          id: 'suggested group id',
+          name: 'suggested group',
+        },
+      };
+
+      element = fixture('basic');
+      element.change = {
+        owner: owner,
+        reviewers: {
+          CC: [existingReviewer1],
+          REVIEWER: [existingReviewer2],
+        },
+      };
+
+      stub('gr-rest-api-interface', {
+        getChangeSuggestedReviewers: function() {
+          var redundantSuggestion1 = {account: existingReviewer1};
+          var redundantSuggestion2 = {account: existingReviewer2};
+          var redundantSuggestion3 = {account: owner};
+          return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
+              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+        },
+      });
+    });
+
+    test('notOwnerOrReviewer', function() {
+      var account = makeAccount();
+      assert.isTrue(element.notOwnerOrReviewer({}));
+      assert.isTrue(element.notOwnerOrReviewer({account: account}));
+      assert.isFalse(element.notOwnerOrReviewer({account: owner}));
+      assert.isFalse(element.notOwnerOrReviewer({account: existingReviewer1}));
+      assert.isFalse(element.notOwnerOrReviewer({account: existingReviewer2}));
+    });
+
+    test('_makeSuggestion formats account or group accordingly', function() {
+      var account = makeAccount();
+      var suggestion = element._makeSuggestion({account: account});
+      assert.deepEqual(suggestion, {
+        name: account.name + ' (' + account.email + ')',
+        value: {account: account},
+      });
+
+      var group = {name: 'test'};
+      suggestion = element._makeSuggestion({group: group});
+      assert.deepEqual(suggestion, {
+        name: group.name + ' (group)',
+        value: {group: group},
+      });
+    });
+
+    test('_getReviewerSuggestions excludes owner+reviewers', function(done) {
+      element._getReviewerSuggestions().then(function(reviewers) {
+        assert.deepEqual(reviewers, [
+            element._makeSuggestion(suggestion1),
+            element._makeSuggestion(suggestion2),
+            element._makeSuggestion(suggestion3),
+        ]);
+        done();
+      });
+    });
+
+    test('_updateReviewers', function() {
+      // delete existingReviewer1
+      element.splice('change.reviewers.CC', 0, 1);
+      var expected = {};
+      expected[owner._account_id] = true;
+      expected[existingReviewer2._account_id] = true;
+      assert.deepEqual(element._reviewers, expected);
+
+      // delete existingReviewer2
+      element.splice('change.reviewers.REVIEWER', 0, 1);
+      delete expected[existingReviewer2._account_id];
+      assert.deepEqual(element._reviewers, expected);
+
+      // add two new reviewers
+      var account1 = makeAccount();
+      var account2 = makeAccount();
+      element.push('change.reviewers.CC', account1);
+      element.push('change.reviewers.REVIEWER', account2);
+      expected[account1._account_id] = true;
+      expected[account2._account_id] = true;
+      assert.deepEqual(element._reviewers, expected);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
new file mode 100644
index 0000000..e074e15
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -0,0 +1,53 @@
+<!--
+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">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
+<link rel="import" href="../gr-account-entry/gr-account-entry.html">
+
+<dom-module id="gr-account-list">
+  <template>
+    <style>
+      gr-account-chip {
+        display: inline-block;
+      }
+      .group {
+        --account-label-suffix: ' (group)';
+      }
+      .pending-add {
+        font-style: italic;
+      }
+    </style>
+    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
+      <gr-account-chip
+          account="[[account]]"
+          class$="[[_computeChipClass(account)]]"
+          data-account-id$="[[account._account_id]]"
+          removable="[[_computeRemovable(account)]]">
+      </gr-account-chip>
+    </template>
+    <gr-account-entry
+        borderless
+        hidden$="[[readonly]]"
+        id="entry"
+        change="[[change]]"
+        filter="[[filter]]"
+        placeholder="[[placeholder]]"
+        on-add="_handleAdd">
+    </gr-account-entry>
+  </template>
+  <script src="gr-account-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
new file mode 100644
index 0000000..828169d
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -0,0 +1,130 @@
+// 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';
+
+  Polymer({
+    is: 'gr-account-list',
+
+    properties: {
+      accounts: {
+        type: Array,
+        value: function() { return []; },
+      },
+      change: Object,
+      placeholder: String,
+      readonly: Boolean,
+
+      filter: {
+        type: Function,
+        value: function() {
+          return this._filterSuggestion.bind(this);
+        },
+      },
+    },
+
+    listeners: {
+      'remove': '_handleRemove',
+    },
+
+    get focusStart() {
+      return this.$.entry.focusStart;
+    },
+
+    _handleAdd: function(e) {
+      var reviewer = e.detail.value;
+      // Append new account or group to the accounts property. We add our own
+      // internal properties to the account/group here, so we clone the object
+      // to avoid cluttering up the shared change object.
+      // TODO(logan): Polyfill for Object.assign in IE.
+      if (reviewer.account) {
+        var account = Object.assign({}, reviewer.account, {_pendingAdd: true});
+        this.push('accounts', account);
+      } else if (reviewer.group) {
+        var group = Object.assign({}, reviewer.group,
+            {_pendingAdd: true, _group: true});
+        this.push('accounts', group);
+      }
+    },
+
+    _computeChipClass: function(account) {
+      var classes = [];
+      if (account._group) {
+        classes.push('group');
+      }
+      if (account._pendingAdd) {
+        classes.push('pendingAdd');
+      }
+      return classes.join(' ');
+    },
+
+    _computeRemovable: function(account) {
+      return !this.readonly && !!account._pendingAdd;
+    },
+
+    _filterSuggestion: function(reviewer) {
+      if (!this.$.entry.notOwnerOrReviewer(reviewer)) {
+        return false;
+      }
+      for (var i = 0; i < this.accounts.length; i++) {
+        var account = this.accounts[i];
+        if (!account._pendingAdd) {
+          continue;
+        }
+        if (reviewer.group && account._group &&
+            reviewer.group.id === account.id) {
+          return false;
+        }
+        if (reviewer.account && !account._group &&
+            account._account_id === account._account_id) {
+          return false;
+        }
+      }
+      return true;
+    },
+
+    _handleRemove: function(e) {
+      var toRemove = e.detail.account;
+      for (var i = 0; i < this.accounts.length; i++) {
+        var matches;
+        var account = this.accounts[i];
+        if (toRemove._group) {
+          matches = toRemove.id === account.id;
+        } else {
+          matches = toRemove._account_id === account._account_id;
+        }
+        if (matches) {
+          this.splice('accounts', i, 1);
+          return;
+        }
+      }
+      console.warn('received remove event for missing account',
+          e.detail.account);
+    },
+
+    additions: function() {
+      var result = [];
+      return this.accounts.filter(function(account) {
+        return account._pendingAdd;
+      }).map(function(account) {
+        if (account._group) {
+          return {group: account};
+        } else {
+          return {account: account};
+        }
+      });
+      return result;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
new file mode 100644
index 0000000..79d5e59
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -0,0 +1,203 @@
+<!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-account-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-account-list.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-account-list></gr-account-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-account-list tests', function() {
+    var _nextAccountId = 0;
+    var makeAccount = function() {
+      var accountId = ++_nextAccountId;
+      return {
+        _account_id: accountId,
+      };
+    };
+    var makeGroup = function() {
+      var groupId = 'group' + (++_nextAccountId);
+      return {
+        id: groupId,
+      };
+    };
+
+    var existingReviewer1;
+    var existingReviewer2;
+    var element;
+
+    function getChips() {
+      return Polymer.dom(element.root).querySelectorAll('gr-account-chip');
+    }
+
+    setup(function() {
+      existingReviewer1 = makeAccount();
+      existingReviewer2 = makeAccount();
+
+      element = fixture('basic');
+      element.accounts = [existingReviewer1, existingReviewer2];
+
+      stub('gr-rest-api-interface', {
+        getConfig: function() {
+          return Promise.resolve({});
+        },
+      });
+    });
+
+    test('account entry only appears when editable', function() {
+      element.readonly = false;
+      assert.isFalse(element.$.entry.hasAttribute('hidden'));
+      element.readonly = true;
+      assert.isTrue(element.$.entry.hasAttribute('hidden'));
+    });
+
+    test('addition and removal of account/group chips', function() {
+      flushAsynchronousOperations();
+
+      // Existing accounts are listed.
+      var chips = getChips();
+      assert.equal(chips.length, 2);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+      assert.isFalse(chips[1].classList.contains('pendingAdd'));
+
+      // New accounts are added to end with pendingAdd class.
+      var newAccount = makeAccount();
+      element._handleAdd({
+        detail: {
+          value: {
+            account: newAccount,
+          },
+        },
+      });
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 3);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+      assert.isFalse(chips[1].classList.contains('pendingAdd'));
+      assert.isTrue(chips[2].classList.contains('pendingAdd'));
+
+      // Removed accounts are taken out of the list.
+      element.fire('remove', {account: existingReviewer1});
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 2);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+      assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+      // Invalid remove is ignored.
+      element.fire('remove', {account: existingReviewer1});
+      element.fire('remove', {account: newAccount});
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 1);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+
+      // New groups are added to end with pendingAdd and group classes.
+      var newGroup = makeGroup();
+      element._handleAdd({
+        detail: {
+          value: {
+            group: newGroup,
+          },
+        },
+      });
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 2);
+      assert.isTrue(chips[1].classList.contains('group'));
+      assert.isTrue(chips[1].classList.contains('pendingAdd'));
+
+      // Removed groups are taken out of the list.
+      element.fire('remove', {account: newGroup});
+      flushAsynchronousOperations();
+      chips = getChips();
+      assert.equal(chips.length, 1);
+      assert.isFalse(chips[0].classList.contains('pendingAdd'));
+    });
+
+    test('_computeChipClass', function() {
+      var account = makeAccount();
+      assert.equal(element._computeChipClass(account), '');
+      account._pendingAdd = true;
+      assert.equal(element._computeChipClass(account), 'pendingAdd');
+      account._group = true;
+      assert.equal(element._computeChipClass(account), 'group pendingAdd');
+      account._pendingAdd = false;
+      assert.equal(element._computeChipClass(account), 'group');
+    });
+
+    test('_computeRemovable', function() {
+      var newAccount = makeAccount();
+      newAccount._pendingAdd = true;
+      element.readonly = false;
+      assert.isFalse(element._computeRemovable(existingReviewer1));
+      assert.isTrue(element._computeRemovable(newAccount));
+
+      element.readonly = true;
+      assert.isFalse(element._computeRemovable(existingReviewer1));
+      assert.isFalse(element._computeRemovable(newAccount));
+    });
+
+    test('additions returns sanitized new accounts and groups', function() {
+      assert.equal(element.additions().length, 0);
+
+      var newAccount = makeAccount();
+      element._handleAdd({
+        detail: {
+          value: {
+            account: newAccount,
+          },
+        },
+      });
+      var newGroup = makeGroup();
+      element._handleAdd({
+        detail: {
+          value: {
+            group: newGroup,
+          },
+        },
+      });
+
+      assert.deepEqual(element.additions(), [
+        {
+          account: {
+            _account_id: newAccount._account_id,
+            _pendingAdd: true,
+          },
+        },
+        {
+          group: {
+            id: newGroup.id,
+            _group: true,
+            _pendingAdd: true,
+          },
+        },
+      ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index a0a9305..9ddd66d9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -93,7 +93,7 @@
       <span class="title">Reviewers</span>
       <span class="value">
         <gr-reviewer-list
-            change="[[change]]"
+            change="{{change}}"
             mutable="[[mutable]]"
             suggest-from="[[serverConfig.suggest.from]]"></gr-reviewer-list>
       </span>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 79f20ab..b90bed8 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -237,7 +237,7 @@
                   <option value$="[[patchNumber]]" selected$="[[_computePatchIndexIsSelected(index, _patchRange.patchNum)]]">
                     <span>[[patchNumber]]</span>
                     /
-                    <span>[[_computeLatestPatchNum(_change)]]</span>
+                    <span>[[_computeLatestPatchNum(_allPatchSets)]]</span>
                   </option>
                 </template>
               </select>
@@ -248,10 +248,12 @@
       <section class="changeInfo">
         <div class="changeInfo-column changeMetadata">
           <gr-change-metadata
-              change="[[_change]]"
+              change="{{_change}}"
               commit-info="[[_commitInfo]]"
               server-config="[[serverConfig]]"
-              mutable="[[_loggedIn]]"></gr-change-metadata>
+              mutable="[[_loggedIn]]"
+              on-show-reply-dialog="_handleShowReplyDialog">
+          </gr-change-metadata>
           <gr-change-actions id="actions"
               actions="[[_change.actions]]"
               change-num="[[_changeNum]]"
@@ -311,7 +313,7 @@
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
-          change-num="[[_changeNum]]"
+          change="[[_change]]"
           patch-num="[[_patchRange.patchNum]]"
           revisions="[[_change.revisions]]"
           labels="[[_change.labels]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index f19d528..ac1855b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -189,6 +189,7 @@
       if (isNaN(patchNum)) { return true; }
 
       var change = changeRecord.base;
+      if (!change.current_revision) { return true; }
       if (change.revisions[change.current_revision]._number !== patchNum) {
         return true;
       }
@@ -266,8 +267,13 @@
 
     _handlePatchChange: function(e) {
       var patchNum = e.target.value;
-      var currentPatchNum =
-          this._change.revisions[this._change.current_revision]._number;
+      var currentPatchNum;
+      if (this._change.current_revision) {
+        currentPatchNum =
+            this._change.revisions[this._change.current_revision]._number;
+      } else {
+        currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
+      }
       if (patchNum == currentPatchNum) {
         page.show(this.changePath(this._changeNum));
         return;
@@ -310,6 +316,10 @@
       this.$.replyOverlay.close();
     },
 
+    _handleShowReplyDialog: function(e) {
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.REVIEWERS);
+    },
+
     _paramsChanged: function(value) {
       if (value.view !== this.tagName.toLowerCase()) { return; }
 
@@ -378,7 +388,7 @@
           this._patchRange.basePatchNum || 'PARENT');
       this.set('_patchRange.patchNum',
           this._patchRange.patchNum ||
-              change.revisions[change.current_revision]._number);
+              this._computeLatestPatchNum(this._allPatchSets));
 
       var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
       this.fire('title-change', {title: title});
@@ -399,8 +409,8 @@
       return '(' + status.toLowerCase() + ')';
     },
 
-    _computeLatestPatchNum: function(change) {
-      return change.revisions[change.current_revision]._number;
+    _computeLatestPatchNum: function(allPatchSets) {
+      return allPatchSets[allPatchSets.length - 1];
     },
 
     _computeAllPatchSets: function(change) {
@@ -496,9 +506,10 @@
       });
     },
 
-    _openReplyDialog: function() {
+    _openReplyDialog: function(opt_section) {
       this.$.replyOverlay.open().then(function() {
         this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+        this.$.replyDialog.focusOn(opt_section);
       }.bind(this));
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 49e0239..bba0a63 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -170,6 +170,58 @@
       element.fire('change', {}, {node: selectEl});
     });
 
+    test('patch num change with missing current_revision', function(done) {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        status: 'NEW',
+        labels: {},
+      };
+      flushAsynchronousOperations();
+      var selectEl = element.$$('.header select');
+      assert.ok(selectEl);
+      var optionEls =
+          Polymer.dom(element.root).querySelectorAll('.header option');
+      assert.equal(optionEls.length, 4);
+      assert.isFalse(
+          element.$$('.header option[value="1"]').hasAttribute('selected'));
+      assert.isTrue(
+          element.$$('.header option[value="2"]').hasAttribute('selected'));
+      assert.isFalse(
+          element.$$('.header option[value="3"]').hasAttribute('selected'));
+      assert.equal(optionEls[3].value, 13);
+
+      var showStub = sinon.stub(page, 'show');
+
+      var numEvents = 0;
+      selectEl.addEventListener('change', function(e) {
+        numEvents++;
+        if (numEvents == 1) {
+          assert(showStub.lastCall.calledWithExactly('/c/42/1'),
+              'Should navigate to /c/42/1');
+          selectEl.value = '3';
+          element.fire('change', {}, {node: selectEl});
+        } else if (numEvents == 2) {
+          assert(showStub.lastCall.calledWithExactly('/c/42/3'),
+              'Should navigate to /c/42/3');
+          showStub.restore();
+          done();
+        }
+      });
+      selectEl.value = '1';
+      element.fire('change', {}, {node: selectEl});
+    });
+
     test('change status new', function() {
       element._changeNum = '1';
       element._patchRange = {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index 556691a..89cb4a2 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -21,6 +21,7 @@
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-account-list/gr-account-list.html">
 
 <dom-module id="gr-reply-dialog">
   <template>
@@ -43,11 +44,26 @@
       section {
         border-top: 1px solid #ddd;
         padding: .5em .75em;
+        width: 100%;
       }
       .labelsContainer,
       .actionsContainer {
         flex-shrink: 0;
       }
+      .peopleContainer {
+        display: table;
+      }
+      .peopleList {
+        display: flex;
+      }
+      .peopleListLabel {
+        color: #666;
+        min-width: 7em;
+        padding-right: .5em;
+      }
+      gr-account-list {
+        flex: 1;
+      }
       .textareaContainer {
         position: relative;
         display: flex;
@@ -105,6 +121,24 @@
       }
     </style>
     <div class="container">
+      <section class="peopleContainer">
+        <div class="peopleList">
+          <div class="peopleListLabel">Owner</div>
+          <gr-account-list readonly accounts="[[_owners]]">
+          </gr-account-list>
+        </div>
+      </section>
+      <section class="peopleContainer">
+        <div class="peopleList">
+          <div class="peopleListLabel">Reviewers</div>
+          <gr-account-list
+              id="reviewers"
+              accounts="[[_reviewers]]"
+              change="[[change]]"
+              placeholder="Add reviewer...">
+          </gr-account-list>
+        </div>
+      </section>
       <section class="textareaContainer">
         <iron-autogrow-textarea
             id="textarea"
@@ -136,7 +170,7 @@
         <template is="dom-if" if="[[!_computeShowLabels(patchNum, revisions)]]">
           <span class="labelsNotShown">
             Labels are not shown because this is not the most recent patch set.
-            <a href$="/c/[[changeNum]]">Go to the latest patch set.</a>
+            <a href$="/c/[[change._number]]">Go to the latest patch set.</a>
           </span>
         </template>
       </section>
@@ -144,7 +178,7 @@
         <h3>[[_computeDraftsTitle(diffDrafts)]]</h3>
         <gr-comment-list
             comments="[[diffDrafts]]"
-            change-num="[[changeNum]]"
+            change-num="[[change._number]]"
             patch-num="[[patchNum]]"></gr-comment-list>
       </section>
       <section class="actionsContainer">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 0ee779e..9c2d023 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -14,6 +14,11 @@
 (function() {
   'use strict';
 
+  var FocusTarget = {
+    BODY: 'body',
+    REVIEWERS: 'reviewers',
+  };
+
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -30,7 +35,7 @@
      */
 
     properties: {
-      changeNum: String,
+      change: Object,
       patchNum: String,
       revisions: Object,
       disabled: {
@@ -47,12 +52,20 @@
       permittedLabels: Object,
 
       _account: Object,
+      _owners: Array,
+      _reviewers: Array,
     },
 
+    FocusTarget: FocusTarget,
+
     behaviors: [
       Gerrit.RESTClientBehavior,
     ],
 
+    observers: [
+      '_changeUpdated(change.*)',
+    ],
+
     attached: function() {
       this._getAccount().then(function(account) {
         this._account = account;
@@ -64,14 +77,22 @@
     },
 
     focus: function() {
-      this.async(function() {
-        this.$.textarea.textarea.focus();
-      }.bind(this));
+      this.focusOn(FocusTarget.BODY);
+    },
+
+    focusOn: function(section) {
+      if (section === FocusTarget.BODY) {
+        var textarea = this.$.textarea;
+        textarea.async(textarea.textarea.focus.bind(textarea.textarea));
+      } else if (section === FocusTarget.REVIEWERS) {
+        var reviewerEntry = this.$.reviewers.focusStart;
+        reviewerEntry.async(reviewerEntry.focus);
+      }
     },
 
     getFocusStops: function() {
       return {
-        start: this.$.textarea.$.textarea,
+        start: this.$.reviewers.focusStart,
         end: this.$.cancelButton,
       };
     },
@@ -105,6 +126,21 @@
       if (this.draft != null) {
         obj.message = this.draft;
       }
+
+      var newReviewers = this.$.reviewers.additions();
+      newReviewers.forEach(function(reviewer) {
+        var reviewerId;
+        if (reviewer.account) {
+          reviewerId = reviewer.account._account_id;
+        } else if (reviewer.group) {
+          reviewerId = reviewer.group.id;
+        }
+        if (!obj.reviewers) {
+          obj.reviewers = [];
+        }
+        obj.reviewers.push({reviewer: reviewerId});
+      });
+
       this.disabled = true;
       return this._saveReview(obj).then(function(response) {
         this.disabled = false;
@@ -182,6 +218,32 @@
       return permittedLabels[label];
     },
 
+    _changeUpdated: function(changeRecord) {
+      if (!changeRecord.path || !changeRecord.base) {
+        return;
+      }
+
+      if (changeRecord.path !== 'change' &&
+          changeRecord.path !== 'change.reviewers.CC.splices' &&
+          changeRecord.path !== 'change.reviewers.REVIEWER.splices') {
+        return;
+      }
+
+      var owner = changeRecord.base.owner;
+      this._owners = [owner];
+
+      if (!changeRecord.base.reviewers) {
+        return;
+      }
+
+      var reviewers = changeRecord.base.reviewers.REVIEWER || [];
+      reviewers = reviewers.concat(changeRecord.base.reviewers.CC);
+      reviewers = reviewers.filter(function(account) {
+        return account && account._account_id !== owner._account_id;
+      }.bind(this));
+      this._reviewers = reviewers;
+    },
+
     _getAccount: function() {
       return this.$.restAPI.getAccount();
     },
@@ -197,7 +259,7 @@
     },
 
     _saveReview: function(review) {
-      return this.$.restAPI.saveChangeReview(this.changeNum, this.patchNum,
+      return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
           review);
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 4ff9f04..99e3cd9 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -18,7 +18,6 @@
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-reviewer-list">
@@ -45,19 +44,12 @@
       gr-account-chip {
         margin-top: .3em;
       }
-      .remove,
-      .cancel {
+      .remove {
         color: #999;
       }
       .remove {
         font-size: .9em;
       }
-      .cancel {
-        font-size: 2em;
-        line-height: 1;
-        padding: 0 .15em;
-        text-decoration: none;
-      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         gr-account-chip:first-of-type {
           margin-top: 0;
@@ -72,28 +64,11 @@
       </gr-account-chip>
     </template>
     <div class="controlsContainer" hidden$="[[!mutable]]">
-      <div class="autocompleteContainer" hidden$="[[!_showInput]]">
-        <div class="inputContainer">
-          <gr-autocomplete
-              id="input"
-              threshold="[[suggestFrom]]"
-              clear-on-commit
-              query="[[_query]]"
-              disabled="[[disabled]]"
-              on-commit="_sendAddRequest"
-              on-cancel="_handleCancelTap"></gr-autocomplete>
-          <gr-button
-              link
-              class="cancel"
-              on-tap="_handleCancelTap">×</gr-button>
-        </div>
-      </div>
       <gr-button
           link
           id="addReviewer"
           class="addReviewer"
-          on-tap="_handleAddTap"
-          hidden$="[[_showInput]]">Add reviewer</gr-button>
+          on-tap="_handleAddTap">Add reviewer</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index 1410821..7037fc2 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -17,17 +17,23 @@
   Polymer({
     is: 'gr-reviewer-list',
 
+    /**
+     * Fired when the "Add reviewer..." button is tapped.
+     *
+     * @event show-reply-dialog
+     */
+
     properties: {
       change: Object,
-      mutable: {
-        type: Boolean,
-        value: false,
-      },
       disabled: {
         type: Boolean,
         value: false,
         reflectToAttribute: true,
       },
+      mutable: {
+        type: Boolean,
+        value: false,
+      },
       suggestFrom: {
         type: Number,
         value: 3,
@@ -42,13 +48,6 @@
         value: false,
       },
 
-      _query: {
-        type: Function,
-        value: function() {
-          return this._getReviewerSuggestions.bind(this);
-        },
-      },
-
       // Used for testing.
       _lastAutocompleteRequest: Object,
       _xhrPromise: Object,
@@ -111,98 +110,11 @@
 
     _handleAddTap: function(e) {
       e.preventDefault();
-      this._showInput = true;
-      this.$.input.focus();
-    },
-
-    _handleCancelTap: function(e) {
-      e.preventDefault();
-      this.$.input.clear();
-      this._cancel();
-    },
-
-    _cancel: function() {
-      this._showInput = false;
-      this.$.input.clear();
-      this.$.addReviewer.focus();
-    },
-
-    _sendAddRequest: function(e, detail) {
-      var reviewer = detail.value;
-      var reviewerID;
-      if (reviewer.account) {
-        reviewerID = reviewer.account._account_id;
-      } else if (reviewer.group) {
-        reviewerID = reviewer.group.id;
-      }
-
-      this.disabled = true;
-      this._xhrPromise = this._addReviewer(reviewerID).then(function(response) {
-        this.change.reviewers.CC = this.change.reviewers.CC || [];
-        this.disabled = false;
-        if (!response.ok) { return response; }
-
-        return this.$.restAPI.getResponseObject(response).then(function(obj) {
-          obj.reviewers.forEach(function(r) {
-            this.push('change.removable_reviewers', r);
-            this.push('change.reviewers.CC', r);
-          }, this);
-          this.$.input.focus();
-        }.bind(this));
-      }.bind(this)).catch(function(err) {
-        this.disabled = false;
-        throw err;
-      }.bind(this));
-    },
-
-    _addReviewer: function(id) {
-      return this.$.restAPI.addChangeReviewer(this.change._number, id);
+      this.fire('show-reply-dialog');
     },
 
     _removeReviewer: function(id) {
       return this.$.restAPI.removeChangeReviewer(this.change._number, id);
     },
-
-    _notInList: function(reviewer) {
-      var account = reviewer.account;
-      if (!account) { return true; }
-      if (account._account_id === this.change.owner._account_id) {
-        return false;
-      }
-      for (var i = 0; i < this._reviewers.length; i++) {
-        if (account._account_id === this._reviewers[i]._account_id) {
-          return false;
-        }
-      }
-      return true;
-    },
-
-    _makeSuggestion: function(reviewer) {
-      if (reviewer.account) {
-        return {
-          name: reviewer.account.name + ' (' + reviewer.account.email + ')',
-          value: reviewer,
-        };
-      } else if (reviewer.group) {
-        return {
-          name: reviewer.group.name + ' (group)',
-          value: reviewer,
-        };
-      }
-    },
-
-    _getReviewerSuggestions: function(input) {
-      var xhr = this.$.restAPI.getChangeSuggestedReviewers(
-          this.change._number, input);
-
-      this._lastAutocompleteRequest = xhr;
-
-      return xhr.then(function(reviewers) {
-        if (!reviewers) { return []; }
-        return reviewers
-            .filter(this._notInList.bind(this))
-            .map(this._makeSuggestion);
-      }.bind(this));
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 9be76d9..e6f7a20 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -34,56 +34,10 @@
 <script>
   suite('gr-reviewer-list tests', function() {
     var element;
-    var autocompleteInput;
 
     setup(function() {
       element = fixture('basic');
-      autocompleteInput = element.$.input.$.input;
       stub('gr-rest-api-interface', {
-        getChangeSuggestedReviewers: function() {
-          return Promise.resolve([
-            {
-              account: {
-                _account_id: 1021482,
-                name: 'Andrew Bonventre',
-                email: 'andybons@chromium.org',
-              }
-            },
-            {
-              account: {
-                _account_id: 1021863,
-                name: 'Andrew Bonventre',
-                email: 'andybons@google.com',
-              }
-            },
-            {
-              group: {
-                id: 'c7af6dd375c092ff3b23c0937aa910693dc0c41b',
-                name: 'andy',
-              }
-            }
-          ]);
-        },
-        addChangeReviewer: function() {
-          return Promise.resolve({
-            ok: true,
-            text: function() {
-              return Promise.resolve(
-                ')]}\'\n' +
-                JSON.stringify({
-                  reviewers: [{
-                    _account_id: 1021482,
-                    approvals: {
-                      'Code-Review': ' 0'
-                    },
-                    email: 'andybons@chromium.org',
-                    name: 'Andrew Bonventre',
-                  }]
-                })
-              );
-            },
-          });
-        },
         removeChangeReviewer: function() {
           return Promise.resolve({ok: true});
         },
@@ -97,30 +51,11 @@
       assert.isFalse(element.$$('.controlsContainer').hasAttribute('hidden'));
     });
 
-    function getActiveElement() {
-      return document.activeElement.shadowRoot ?
-          document.activeElement.shadowRoot.activeElement :
-          document.activeElement;
-    }
-
-    test('show/hide input', function() {
-      element.mutable = true;
-      assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden'));
-      assert.isTrue(
-          element.$$('.autocompleteContainer').hasAttribute('hidden'));
-      assert.notEqual(getActiveElement().id, 'input');
+    test('add reviewer button opens reply dialog', function(done) {
+      element.addEventListener('show-reply-dialog', function() {
+        done();
+      });
       MockInteractions.tap(element.$$('.addReviewer'));
-      assert.isTrue(element.$$('.addReviewer').hasAttribute('hidden'));
-      assert.isFalse(
-          element.$$('.autocompleteContainer').hasAttribute('hidden'));
-      assert.equal(getActiveElement().id, 'input');
-
-      MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 27); // 'esc'
-
-      assert.isFalse(element.$$('.addReviewer').hasAttribute('hidden'));
-      assert.isTrue(
-          element.$$('.autocompleteContainer').hasAttribute('hidden'));
-      assert.equal(getActiveElement().id, 'addReviewer');
     });
 
     test('only show remove for removable reviewers', function() {
@@ -178,127 +113,5 @@
         }
       });
     });
-
-    test('autocomplete starts at >= 3 chars', function() {
-      element._inputRequestTimeout = 0;
-      element._mutable = true;
-      element.change = {_number: 123};
-
-      element.$.input.text = 'fo';
-
-      flushAsynchronousOperations();
-
-      assert.isFalse(element.$.restAPI.getChangeSuggestedReviewers.called);
-    });
-
-    test('add/remove reviewer flow', function(done) {
-      element.change = {
-        _number: 42,
-        reviewers: {},
-        removable_reviewers: [],
-        owner: {_account_id: 0},
-      };
-      element._inputRequestTimeout = 0;
-      element._mutable = true;
-      MockInteractions.tap(element.$$('.addReviewer'));
-      flushAsynchronousOperations();
-      element.$.input.text = 'andy';
-
-      element._lastAutocompleteRequest.then(function() {
-        flushAsynchronousOperations();
-
-        MockInteractions.pressAndReleaseKeyOn(autocompleteInput, 27); // 'esc'
-        assert.isTrue(element.$$('.autocompleteContainer')
-            .hasAttribute('hidden'));
-
-        MockInteractions.tap(element.$$('.addReviewer'));
-
-        element.$.input.text = 'andyb';
-        element._lastAutocompleteRequest.then(function() {
-
-          MockInteractions.pressAndReleaseKeyOn(
-              autocompleteInput, 13); // 'enter'
-          assert.isTrue(element.disabled);
-
-          element._xhrPromise.then(function() {
-            assert.isFalse(element.disabled);
-            flushAsynchronousOperations();
-            var reviewerEls =
-                Polymer.dom(element.root).querySelectorAll('.reviewer');
-            assert.equal(reviewerEls.length, 1);
-            MockInteractions.tap(element.$$('.reviewer').$$('gr-button'));
-            flushAsynchronousOperations();
-            assert.isTrue(element.disabled);
-
-            element._xhrPromise.then(function() {
-              flushAsynchronousOperations();
-              assert.isFalse(element.disabled);
-              var reviewerEls =
-                  Polymer.dom(element.root).querySelectorAll('.reviewer');
-              assert.equal(reviewerEls.length, 0);
-              done();
-            });
-          });
-        });
-      });
-    });
-
-    test('_makeSuggestion', function() {
-      var account = {
-        _account_id: 123456,
-        name: 'name',
-        email: 'email'
-      };
-      var group = {
-        id: '123456',
-        name: 'name',
-      };
-
-      var suggestion = element._makeSuggestion({account: account});
-
-      assert.deepEqual(suggestion, {
-        name: 'name (email)',
-        value: {account: account},
-      });
-
-      suggestion = element._makeSuggestion({group: group});
-
-      assert.deepEqual(suggestion, {
-        name: 'name (group)',
-        value: {group: group},
-      });
-    });
-
-    test('_notInList', function() {
-      var group = {
-        id: '123456',
-        name: 'name',
-      };
-      var account = {
-        _account_id: 123456,
-        name: 'name',
-        email: 'email',
-      };
-
-      element.change = {owner: {_account_id: 123456}};
-
-      // Is true when passing a group.
-      assert.isTrue(element._notInList({group: group}));
-
-      // Is false when passing the change owner.
-      assert.isFalse(element._notInList({account: account}));
-
-      element.change.owner._account_id = 789;
-
-      // Is true when passing a different user than the change owner, and is not
-      // in the reviewer list.
-      assert.isTrue(element._notInList({account: account}));
-
-      element._reviewers = [{_account_id: 123456}];
-
-      // Is false when passing a different user than the change owner, but *is*
-      // the reviewer list.
-      assert.isFalse(element._notInList({account: account}));
-    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 90e1ef8..71774e3 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -50,7 +50,7 @@
     test('show auth error', function(done) {
       var showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
       element.fire('server-error', {response: {status: 403}});
-      flush(function() {
+      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
         assert.isTrue(showAuthErrorStub.calledOnce);
         done();
       });
@@ -90,7 +90,7 @@
       var toastSpy = sandbox.spy(element, '_createToastAlert');
       var windowOpen = sandbox.stub(window, 'open');
       element.fire('server-error', {response: {status: 403}});
-      flush(function() {
+      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
         assert.isTrue(toastSpy.called);
         var toast = toastSpy.lastCall.returnValue;
         assert.isOk(toast);
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 feb21e2..6b1e59e 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
@@ -19,7 +19,7 @@
 
   function GrDiffBuilderImage(diff, comments, prefs, outputEl, baseImage,
       revisionImage) {
-    GrDiffBuilderSideBySide.call(this, diff, comments, prefs, outputEl);
+    GrDiffBuilderSideBySide.call(this, diff, comments, prefs, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index 1044b77..0d9ecbc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -17,8 +17,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(diff, comments, prefs, outputEl) {
-    GrDiffBuilder.call(this, diff, comments, prefs, outputEl);
+  function GrDiffBuilderSideBySide(diff, comments, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, prefs, outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
@@ -57,7 +57,7 @@
     if (action) {
       row.appendChild(action);
     } else {
-      var textEl = this._createTextEl(line);
+      var textEl = this._createTextEl(line, side);
       var threadEl = this._commentThreadForLine(line, side);
       if (threadEl) {
         textEl.appendChild(threadEl);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index e69f369..d9c9b1b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -17,8 +17,8 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(diff, comments, prefs, outputEl) {
-    GrDiffBuilder.call(this, diff, comments, prefs, outputEl);
+  function GrDiffBuilderUnified(diff, comments, prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, comments, prefs, outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
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 bf78708..0aa5c94 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
@@ -15,12 +15,16 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
+<link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
 
 <dom-module id="gr-diff-builder">
   <template>
     <div class="contentWrapper">
       <content></content>
     </div>
+    <gr-ranged-comment-layer
+        id="rangeLayer"
+        comments="[[comments]]"></gr-ranged-comment-layer>
     <gr-diff-processor
         id="processor"
         groups="{{_groups}}"></gr-diff-processor>
@@ -52,11 +56,13 @@
 
         properties: {
           viewMode: String,
+          comments: Object,
           isImageDiff: Boolean,
           baseImage: Object,
           revisionImage: Object,
           _builder: Object,
           _groups: Array,
+          _layers: Array,
         },
 
         get diffElement() {
@@ -67,6 +73,14 @@
           '_groupsChanged(_groups.splices)',
         ],
 
+        attached: function() {
+          // Setup annotation layers.
+          this._layers = [
+            this._createIntralineLayer(),
+            this.$.rangeLayer,
+          ];
+        },
+
         render: function(diff, comments, prefs) {
           // Stop the processor (if it's running).
           this.$.processor.cancel();
@@ -216,10 +230,10 @@
                 this.diffElement, this.baseImage, this.revisionImage);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
             return new GrDiffBuilderSideBySide(
-                diff, comments, prefs, this.diffElement);
+                diff, comments, prefs, this.diffElement, this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
             return new GrDiffBuilderUnified(
-                diff, comments, prefs, this.diffElement);
+                diff, comments, prefs, this.diffElement, this._layers);
           }
           throw Error('Unsupported diff view mode: ' + this.viewMode);
         },
@@ -256,6 +270,35 @@
             }
           }, this);
         },
+
+        _createIntralineLayer: function() {
+          return {
+            addListener: function() {},
+
+            // Take a DIV.contentText element and a line object with intraline
+            // differences to highlight and apply them to the element as
+            // annotations.
+            annotate: function(el, line, GrAnnotation) {
+              var HL_CLASS = 'style-scope gr-diff';
+              line.highlights.forEach(function(highlight) {
+                // The start and end indices could be the same if a highlight is
+                // meant to start at the end of a line and continue onto the
+                // next one. Ignore it.
+                if (highlight.startIndex === highlight.endIndex) { return; }
+
+                // If endIndex isn't present, continue to the end of the line.
+                var endIndex = highlight.endIndex === undefined ?
+                    line.text.length : highlight.endIndex;
+
+                GrAnnotation.annotateElement(
+                    el,
+                    highlight.startIndex,
+                    endIndex - highlight.startIndex,
+                    HL_CLASS);
+              });
+            },
+          };
+        },
       });
     })();
   </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 fb2d230..389d290 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
@@ -19,12 +19,18 @@
 
   var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, comments, prefs, outputEl) {
+  function GrDiffBuilder(diff, comments, prefs, outputEl, layers) {
     this._diff = diff;
     this._comments = comments;
     this._prefs = prefs;
     this._outputEl = outputEl;
     this.groups = [];
+
+    this.layers = layers || [];
+
+    this.layers.forEach(function(layer) {
+      layer.addListener(this._handleLayerUpdate.bind(this));
+    }.bind(this));
   }
 
   GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
@@ -165,7 +171,8 @@
     for (var i = 0; i < lines.length; i++) {
       line = lines[i];
       el = elements[i];
-      el.parentElement.replaceChild(this._createTextEl(line).firstChild, el);
+      el.parentElement.replaceChild(this._createTextEl(line, side).firstChild,
+          el);
     }
   };
 
@@ -350,7 +357,7 @@
     return td;
   };
 
-  GrDiffBuilder.prototype._createTextEl = function(line) {
+  GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
     var td = this._createElement('td');
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
@@ -366,6 +373,9 @@
     }
 
     var contentText = this._createElement('div', 'contentText');
+    if (opt_side) {
+      contentText.setAttribute('data-side', opt_side);
+    }
 
     // If the html is equivalent to the text then it didn't get highlighted
     // or escaped. Use textContent which is faster than innerHTML.
@@ -377,9 +387,10 @@
 
     td.classList.add(line.highlights.length > 0 ?
         'lightHighlight' : 'darkHighlight');
-    if (line.highlights.length > 0) {
-      this._addIntralineHighlights(contentText, line);
-    }
+
+    this.layers.forEach(function(layer) {
+      layer.annotate(contentText, line, GrAnnotation);
+    });
 
     td.appendChild(contentText);
 
@@ -488,33 +499,6 @@
     return result;
   };
 
-  /**
-   * Take a DIV.contentText element and a line object with intraline differences
-   * to highlight and apply them to the element as annotations.
-   * @param {HTMLDivElement} el
-   * @param {[type]} line
-   */
-  GrDiffBuilder.prototype._addIntralineHighlights = function(el, line) {
-    var HL_CLASS = 'style-scope gr-diff';
-
-    line.highlights.forEach(function(highlight) {
-      // The start and end indices could be the same if a highlight is meant
-      // to start at the end of a line and continue onto the next one.
-      // Ignore it.
-      if (highlight.startIndex === highlight.endIndex) { return; }
-
-      // If endIndex isn't present, continue to the end of the line.
-      var endIndex = highlight.endIndex === undefined ?
-          line.text.length : highlight.endIndex;
-
-      GrAnnotation.annotateElement(
-          el,
-          highlight.startIndex,
-          endIndex - highlight.startIndex,
-          HL_CLASS);
-    });
-  };
-
   GrDiffBuilder.prototype._getTabWrapper = function(tabSize, showTabs) {
     // Force this to be a number to prevent arbitrary injection.
     tabSize = +tabSize;
@@ -549,5 +533,9 @@
     return el;
   };
 
+  GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) {
+    this._renderContentByRange(start, end, side);
+  };
+
   window.GrDiffBuilder = GrDiffBuilder;
 })(window, GrDiffGroup, GrDiffLine);
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 12c8f3c..f3d24f7 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
@@ -20,12 +20,13 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../../scripts/util.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
 <script src="../gr-diff/gr-diff-group.js"></script>
+<script src="../gr-diff-highlight/gr-annotation.js"></script>
 <script src="gr-diff-builder.js"></script>
-<script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../gr-diff-cursor/mock-diff-response_test.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-diff-builder.html">
 
 <test-fixture id="basic">
@@ -255,6 +256,7 @@
       var el;
       var str;
       var annotateElementSpy;
+      var layer;
 
       function slice(str, start, end) {
         return Array.from(str).slice(start, end).join('');
@@ -264,19 +266,21 @@
         el = fixture('div-with-text');
         str = el.textContent;
         annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+        layer = document.createElement('gr-diff-builder')
+            ._createIntralineLayer();
       });
 
       teardown(function() {
         annotateElementSpy.restore();
       });
 
-      test('_addIntralineHighlights no highlights', function() {
+      test('annotate no highlights', function() {
         var line = {
           text: str,
           highlights: [],
         };
 
-        GrDiffBuilder.prototype._addIntralineHighlights(el, line);
+        layer.annotate(el, line, GrAnnotation);
 
         // The content is unchanged.
         assert.isFalse(annotateElementSpy.called);
@@ -285,7 +289,7 @@
         assert.equal(str, el.childNodes[0].textContent);
       });
 
-      test('_addIntralineHighlights with highlights', function() {
+      test('annotate with highlights', function() {
         var line = {
           text: str,
           highlights: [
@@ -299,7 +303,7 @@
         var str3 = slice(str, 18, 22);
         var str4 = slice(str, 22);
 
-        GrDiffBuilder.prototype._addIntralineHighlights(el, line);
+        layer.annotate(el, line, GrAnnotation);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 5);
@@ -320,7 +324,7 @@
         assert.equal(el.childNodes[4].textContent, str4);
       });
 
-      test('_addIntralineHighlights without endIndex', function() {
+      test('annotate without endIndex', function() {
         var line = {
           text: str,
           highlights: [
@@ -331,7 +335,7 @@
         var str0 = slice(str, 0, 28);
         var str1 = slice(str, 28);
 
-        GrDiffBuilder.prototype._addIntralineHighlights(el, line);
+        layer.annotate(el, line, GrAnnotation);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 2);
@@ -343,7 +347,7 @@
         assert.equal(el.childNodes[1].textContent, str1);
       });
 
-      test('_addIntralineHighlights ignores empty highlights', function() {
+      test('annotate ignores empty highlights', function() {
         var line = {
           text: str,
           highlights: [
@@ -351,17 +355,16 @@
           ],
         };
 
-        GrDiffBuilder.prototype._addIntralineHighlights(el, line);
+        layer.annotate(el, line, GrAnnotation);
 
         assert.isFalse(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 1);
       });
 
-      test('_addIntralineHighlights handles unicode', function() {
+      test('annotate handles unicode', function() {
         // Put some unicode into the string:
         str = str.replace(/\s/g, '💢');
         el.textContent = str;
-
         var line = {
           text: str,
           highlights: [
@@ -373,7 +376,7 @@
         var str1 = slice(str, 6, 12);
         var str2 = slice(str, 12);
 
-        GrDiffBuilder.prototype._addIntralineHighlights(el, line);
+        layer.annotate(el, line, GrAnnotation);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 3);
@@ -388,7 +391,7 @@
         assert.equal(el.childNodes[2].textContent, str2);
       });
 
-      test('_addIntralineHighlights handles unicode w/o endIndex', function() {
+      test('annotate handles unicode w/o endIndex', function() {
         // Put some unicode into the string:
         str = str.replace(/\s/g, '💢');
         el.textContent = str;
@@ -403,7 +406,7 @@
         var str0 = slice(str, 0, 6);
         var str1 = slice(str, 6);
 
-        GrDiffBuilder.prototype._addIntralineHighlights(el, line);
+        layer.annotate(el, line, GrAnnotation);
 
         assert.isTrue(annotateElementSpy.called);
         assert.equal(el.childNodes.length, 2);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 3baf200..a2d7fd0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -15,7 +15,6 @@
   'use strict';
 
   var STORAGE_DEBOUNCE_INTERVAL = 400;
-  var UPDATE_DEBOUNCE_INTERVAL = 500;
 
   Polymer({
     is: 'gr-diff-comment',
@@ -160,7 +159,7 @@
     _fireUpdate: function() {
       this.debounce('fire-update', function() {
         this.fire('comment-update', this._getEventPayload());
-      }, UPDATE_DEBOUNCE_INTERVAL);
+      });
     },
 
     _draftChanged: function(draft) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index de5eb48..54dfad2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -236,21 +236,19 @@
       element._xhrPromise.then(function(draft) {
         assert(fireStub.calledWith('comment-save'),
                'comment-save should be sent');
-        assert.deepEqual(fireStub.lastCall.args, [
-          'comment-save', {
-            comment: {
-              __draft: true,
-              __draftID: 'temp_draft_id',
-              __editing: false,
-              id: 'baf0414d_40572e03',
-              line: 5,
-              message: 'saved!',
-              path: '/path/to/file',
-              updated: '2015-12-08 21:52:36.177000000',
-            },
-            patchNum: 1,
+        assert.deepEqual(fireStub.lastCall.args[1], {
+          comment: {
+            __draft: true,
+            __draftID: 'temp_draft_id',
+            __editing: false,
+            id: 'baf0414d_40572e03',
+            line: 5,
+            message: 'saved!',
+            path: '/path/to/file',
+            updated: '2015-12-08 21:52:36.177000000',
           },
-        ]);
+          patchNum: 1,
+        });
         assert.isFalse(element.disabled,
                        'Element should be enabled when done creating draft.');
         assert.equal(draft.message, 'saved!');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 4c5ee62..5bdd138 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -25,7 +25,7 @@
 <link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
 <link rel="import" href="./gr-diff-cursor.html">
-<link rel="import" href="./mock-diff-response_test.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 
 <test-fixture id="basic">
   <template>
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 e44086e..54294a1 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
@@ -24,11 +24,11 @@
         position: relative;
       }
       .contentWrapper ::content .range {
-        background-color: #ffd500 !important;
+        background-color: rgba(255,213,0,0.5);
         display: inline;
       }
       .contentWrapper ::content .rangeHighlight {
-        background-color: #ff0 !important;
+        background-color: rgba(255,255,0,0.5);
         display: inline;
       }
     </style>
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 e84f6be..86f660b 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
@@ -14,9 +14,6 @@
 (function() {
   'use strict';
 
-  var RANGE_HIGHLIGHT = 'range';
-  var HOVER_HIGHLIGHT = 'rangeHighlight';
-
   Polymer({
     is: 'gr-diff-highlight',
 
@@ -27,13 +24,9 @@
     },
 
     listeners: {
-      'comment-discard': '_handleCommentDiscard',
       'comment-mouse-out': '_handleCommentMouseOut',
       'comment-mouse-over': '_handleCommentMouseOver',
       'create-comment': '_createComment',
-      'render': '_handleRender',
-      'show-context': '_handleShowContext',
-      'thread-discard': '_handleThreadDiscard',
     },
 
     get diffBuilder() {
@@ -56,21 +49,6 @@
       return !!this.$$('gr-selection-action-box');
     },
 
-    _handleThreadDiscard: function(e) {
-      var comment = e.detail.lastComment;
-      // Comment Element was removed from DOM already.
-      if (comment.range) {
-        this._renderCommentRange(comment, e.target);
-      }
-    },
-
-    _handleCommentDiscard: function(e) {
-      var comment = e.detail.comment;
-      if (comment.range) {
-        this._renderCommentRange(comment, e.target);
-      }
-    },
-
     _handleSelectionChange: function() {
       // Can't use up or down events to handle selection started and/or ended in
       // in comment threads or outside of diff.
@@ -79,45 +57,36 @@
       this.debounce('selectionChange', this._handleSelection, 200);
     },
 
-    _handleRender: function() {
-      this._applyAllHighlights();
-    },
-
-    _handleShowContext: function() {
-      // TODO (viktard): Re-render expanded sections only.
-      this._applyAllHighlights();
-    },
-
     _handleCommentMouseOver: function(e) {
       var comment = e.detail.comment;
-      var range = comment.range;
-      if (!range) {
-        return;
-      }
+      if (!comment.range) { return; }
       var lineEl = this.diffBuilder.getLineElByChild(e.target);
       var side = this.diffBuilder.getSideByLineEl(lineEl);
-      this._applyRangedHighlight(
-          HOVER_HIGHLIGHT, range.start_line, range.start_character,
-          range.end_line, range.end_character, side);
+      var index = this._indexOfComment(side, comment);
+      if (index !== undefined) {
+        this.set(['comments', side, index, '__hovering'], true);
+      }
     },
 
     _handleCommentMouseOut: function(e) {
       var comment = e.detail.comment;
-      var range = comment.range;
-      if (!range) {
-        return;
-      }
+      if (!comment.range) { return; }
       var lineEl = this.diffBuilder.getLineElByChild(e.target);
       var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var contentEls = this.diffBuilder.getContentsByLineRange(
-          range.start_line, range.end_line, side);
-      contentEls.forEach(function(content) {
-        Polymer.dom(content).querySelectorAll('.' + HOVER_HIGHLIGHT).forEach(
-            function(el) {
-              el.classList.remove(HOVER_HIGHLIGHT);
-              el.classList.add(RANGE_HIGHLIGHT);
-            });
-      }, this);
+      var index = this._indexOfComment(side, comment);
+      if (index !== undefined) {
+        this.set(['comments', side, index, '__hovering'], false);
+      }
+    },
+
+    _indexOfComment: function(side, comment) {
+      var idProp = comment.id ? 'id' : '__draftID';
+      for (var i = 0; i < this.comments[side].length; i++) {
+        if (comment[idProp] &&
+            this.comments[side][i][idProp] === comment[idProp]) {
+          return i;
+        }
+      }
     },
 
     /**
@@ -226,26 +195,8 @@
       }
     },
 
-    _renderCommentRange: function(comment, el) {
-      var lineEl = this.diffBuilder.getLineElByChild(el);
-      if (!lineEl) {
-        return;
-      }
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      this._rerenderByLines(
-          comment.range.start_line, comment.range.end_line, side);
-    },
-
     _createComment: function(e) {
       this._removeActionBox();
-      var side = e.detail.side;
-      var range = e.detail.range;
-      if (!range) {
-        return;
-      }
-      this._applyRangedHighlight(
-          RANGE_HIGHLIGHT, range.startLine, range.startChar,
-          range.endLine, range.endChar, side);
     },
 
     _removeActionBoxDebounced: function() {
@@ -314,226 +265,5 @@
         return GrAnnotation.getLength(node);
       }
     },
-
-    /**
-     * Creates hl tag with cssClass for starting side of range highlight.
-     *
-     * @param {!Element} startContent Range start diff content
-     *     aka div.contentText.
-     * @param {!Element} endContent Range end diff content
-     *     aka div.contentText.
-     * @param {number} startOffset Range start within start content.
-     * @param {number} endOffset Range end within end content.
-     * @param {string} cssClass
-     * @return {!Element} Range start node.
-     */
-    _normalizeStart: function(
-        startContent, endContent, startOffset, endOffset, cssClass) {
-      var isOneLine = startContent === endContent;
-      var startNode = startContent.firstChild;
-      var length = endOffset - startOffset;
-
-      if (!startNode) {
-        return startNode;
-      }
-
-      // Skip nodes before startOffset.
-      var nodeLength = this._getLength(startNode);
-      while (startNode && (nodeLength <= startOffset || nodeLength === 0)) {
-        startOffset -= nodeLength;
-        startNode = startNode.nextSibling;
-        nodeLength = startNode && this._getLength(startNode);
-      }
-      if (!startNode) { return null; }
-
-      // Split Text node.
-      if (startNode instanceof Text) {
-        startNode = GrAnnotation.splitAndWrapInHighlight(
-            startNode, startOffset, cssClass);
-        // Edge case: single line, text node wraps the highlight.
-        if (isOneLine && this._getLength(startNode) > length) {
-          var extra = GrAnnotation.splitTextNode(startNode.firstChild, length);
-          startContent.insertBefore(extra, startNode.nextSibling);
-          startContent.normalize();
-        }
-      } else if (startNode.tagName == 'HL') {
-        if (!startNode.classList.contains(cssClass)) {
-          // Edge case: single line, <hl> wraps the highlight.
-          // Should leave wrapping HL's content after the highlight.
-          if (isOneLine && startOffset + length < this._getLength(startNode)) {
-            GrAnnotation.splitNode(startNode, startOffset + length);
-          }
-          startNode = GrAnnotation.splitAndWrapInHighlight(
-              startNode, startOffset, cssClass);
-        }
-      } else if (startNode.tagName == 'SPAN') {
-        startNode = GrAnnotation.splitAndWrapInHighlight(
-            startNode, startOffset, cssClass);
-      } else {
-        startNode = null;
-      }
-      return startNode;
-    },
-
-    /**
-     * Creates hl tag with cssClass for ending side of range highlight.
-     *
-     * @param {!Element} startContent Range start diff content
-     *     aka div.contentText.
-     * @param {!Element} endContent Range end diff content
-     *     aka div.contentText.
-     * @param {number} startOffset Range start within start content.
-     * @param {number} endOffset Range end within end content.
-     * @param {string} cssClass
-     * @return {!Element} Range start node.
-     */
-    _normalizeEnd: function(
-        startContent, endContent, startOffset, endOffset, cssClass) {
-      var endNode = endContent.firstChild;
-
-      if (!endNode) {
-        return endNode;
-      }
-
-      // Find the node where endOffset points at.
-      var nodeLength = this._getLength(endNode);
-      while (endNode && (nodeLength < endOffset || nodeLength === 0)) {
-        endOffset -= nodeLength;
-        endNode = endNode.nextSibling;
-        nodeLength = endNode && this._getLength(endNode);
-      }
-      if (!endNode) { return null; }
-
-      if (endNode instanceof Text) {
-        endNode = GrAnnotation.splitAndWrapInHighlight(
-            endNode, endOffset, cssClass, true);
-      } else if (endNode.tagName == 'HL') {
-        if (!endNode.classList.contains(cssClass)) {
-          // Split text inside HL.
-          var hl = endNode;
-          endNode = GrAnnotation.splitAndWrapInHighlight(
-              endNode, endOffset, cssClass, true);
-          if (hl.textContent.length === 0) {
-            hl.remove();
-          }
-        }
-      } else {
-        endNode = null;
-      }
-      return endNode;
-    },
-
-    /**
-     * Applies highlight to first and last lines in range.
-     *
-     * @param {!Element} startContent Range start diff content
-     *     aka div.contentText.
-     * @param {!Element} endContent Range end diff content
-     *     aka div.contentText.
-     * @param {number} startOffset Range start within start content.
-     * @param {number} endOffset Range end within end content.
-     * @param {string} cssClass
-     */
-    _highlightSides: function(
-        startContent, endContent, startOffset, endOffset, cssClass) {
-      var isOneLine = startContent === endContent;
-      var startNode = this._normalizeStart(
-          startContent, endContent, startOffset, endOffset, cssClass);
-      var endNode = this._normalizeEnd(
-          startContent, endContent, startOffset, endOffset, cssClass);
-
-      // Grow starting highlight until endNode or end of line.
-      if (startNode && startNode != endNode) {
-        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, growEndHl, {left: true});
-        endNode.normalize();
-      }
-    },
-
-    /**
-     * @param {string} cssClass
-     * @param {number} startLine Range start code line number.
-     * @param {number} startCol Range start column number.
-     * @param {number} endLine Range end line number.
-     * @param {number} endCol Range end column number.
-     * @param {string=} opt_side Side selector (right or left).
-     */
-    _applyRangedHighlight: function(
-        cssClass, startLine, startCol, endLine, endCol, opt_side) {
-      var startEl = this.diffBuilder.getContentByLine(startLine, opt_side);
-      var endEl = this.diffBuilder.getContentByLine(endLine, opt_side);
-      this._highlightSides(startEl, endEl, startCol, endCol, cssClass);
-      if (endLine - startLine > 1) {
-        // There is at least one line in between.
-        var contents = this.diffBuilder.getContentsByLineRange(
-            startLine + 1, endLine - 1, opt_side);
-        contents.forEach(function(content) {
-          if (!content.firstChild) {
-            return;
-          }
-          // Wrap contents in highlight.
-          var hl = GrAnnotation.wrapInHighlight(content.firstChild, cssClass);
-          var wrapInHl = function(node) {
-            if (node instanceof Text || node.tagName === 'SPAN') {
-              hl.appendChild(node);
-            } else if (node.tagName === 'HL') {
-              this._traverseContentSiblings(node.firstChild, wrapInHl);
-              node.remove();
-            }
-            return node === content.lastChild;
-          }.bind(this);
-          this._traverseContentSiblings(hl.nextSibling, wrapInHl);
-          hl.normalize();
-        }, this);
-      }
-    },
-
-    _applyAllHighlights: function() {
-      var rangedLeft =
-          this.comments.left.filter(function(item) { return !!item.range; });
-      var rangedRight =
-          this.comments.right.filter(function(item) { return !!item.range; });
-      rangedLeft.forEach(function(item) {
-        var range = item.range;
-        this._applyRangedHighlight(
-            RANGE_HIGHLIGHT, range.start_line, range.start_character,
-            range.end_line, range.end_character, 'left');
-      }, this);
-      rangedRight.forEach(function(item) {
-        var range = item.range;
-        this._applyRangedHighlight(
-            RANGE_HIGHLIGHT, range.start_line, range.start_character,
-            range.end_line, range.end_character, 'right');
-      }, this);
-    },
-
-    _rerenderByLines: function(startLine, endLine, opt_side) {
-      this.async(function() {
-        this.diffBuilder.renderLineRange(startLine, endLine, opt_side);
-      }, 1);
-    },
   });
 })();
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 04e47b1..45adb37 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
@@ -167,42 +167,17 @@
         });
       });
 
-      test('renders lines in comment range on thread discard', function(done) {
-        element.fire('thread-discard', {
-          lastComment: {
-            range: {
-              start_line: 10,
-              end_line: 24,
-            },
-          },
-        });
-        flush(function() {
-          assert.isTrue(
-              builder.renderLineRange.calledWithExactly(10, 24, 'other-side'));
-          done();
-        });
-      });
-
-      test('renders lines in comment range on comment discard', function(done) {
-        element.fire('comment-discard', {
-          comment: {
-            range: {
-              start_line: 10,
-              end_line: 24,
-            },
-          },
-        });
-        flush(function() {
-          assert.isTrue(
-              builder.renderLineRange.calledWithExactly(10, 24, 'other-side'));
-          done();
-        });
-      });
-
       test('comment-mouse-over from line comments is ignored', function() {
-        sandbox.stub(element, '_applyRangedHighlight');
+        sandbox.stub(element, 'set');
         element.fire('comment-mouse-over', {comment: {}});
-        assert.isFalse(element._applyRangedHighlight.called);
+        assert.isFalse(element.set.called);
+      });
+
+      test('comment-mouse-over from ranged comment causes set', function() {
+        sandbox.stub(element, 'set');
+        sandbox.stub(element, '_indexOfComment').returns(0);
+        element.fire('comment-mouse-over', {comment: {range:{}}});
+        assert.isTrue(element.set.called);
       });
 
       test('comment-mouse-out from line comments is ignored', function() {
@@ -210,56 +185,7 @@
         assert.isFalse(builder.getContentsByLineRange.called);
       });
 
-      test('on comment-mouse-out highlight classes are removed', function() {
-        var testEl = fixture('highlighted');
-        builder.getContentsByLineRange.returns([testEl]);
-        element.fire('comment-mouse-out', {
-          comment: {
-            range: {
-              start_line: 3,
-              start_character: 14,
-              end_line: 10,
-              end_character: 24,
-            }
-          }});
-        assert.isTrue(builder.getContentsByLineRange.calledWithExactly(
-            3, 10, 'other-side'));
-        assert.equal(0, testEl.querySelectorAll('.rangeHighlight').length);
-        assert.equal(2, testEl.querySelectorAll('.range').length);
-      });
-
-      test('on comment-mouse-over range is highlighted', function() {
-        sandbox.stub(element, '_applyRangedHighlight');
-        element.fire('comment-mouse-over', {
-          comment: {
-            range: {
-              start_line: 3,
-              start_character: 14,
-              end_line: 10,
-              end_character: 24,
-            },
-          }});
-        assert.isTrue(element._applyRangedHighlight.calledWithExactly(
-            'rangeHighlight', 3, 14, 10, 24, 'other-side'));
-      });
-
-      test('on create-comment range is highlighted', function() {
-        sandbox.stub(element, '_applyRangedHighlight');
-        element.fire('create-comment', {
-          range: {
-            startLine: 3,
-            startChar: 14,
-            endLine: 10,
-            endChar: 24,
-          },
-          side: 'some-side',
-        });
-        assert.isTrue(element._applyRangedHighlight.calledWithExactly(
-            'range', 3, 14, 10, 24, 'some-side'));
-      });
-
       test('on create-comment action box is removed', function() {
-        sandbox.stub(element, '_applyRangedHighlight');
         sandbox.stub(element, '_removeActionBox');
         element.fire('create-comment', {
           comment: {
@@ -270,290 +196,6 @@
       });
     });
 
-    test('apply multiline highlight', function() {
-      var diff = element.querySelector('#diffTable');
-      var startContent = diff.querySelector(
-          '.left.lineNum[data-value="138"] ~ .content .contentText');
-      var betweenContent = diff.querySelector(
-          '.left.lineNum[data-value="140"] ~ .content .contentText');
-      var endContent = diff.querySelector(
-          '.left.lineNum[data-value="141"] ~ .content .contentText');
-      var builder = {
-        getContentByLine: sandbox.stub().returns({}),
-        getContentsByLineRange: sandbox.stub().returns([betweenContent]),
-        getLineElByChild: sandbox.stub().returns(
-            {getAttribute: sandbox.stub()}),
-      };
-      element._cachedDiffBuilder = builder;
-      builder.getContentByLine.withArgs(138, 'left').returns(
-          startContent);
-      builder.getContentByLine.withArgs(141, 'left').returns(
-          endContent);
-      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);
-      assert.equal(startContent.childNodes[1].textContent,
-          ' Nam cum ad me in Cumanum salutandi causa uterque venisset,');
-      assert.equal(startContent.childNodes[1].tagName, 'HL');
-      assert.equal(startContent.childNodes[1].className, 'some');
-
-      assert.instanceOf(betweenContent.firstChild, Element);
-      assert.equal(betweenContent.firstChild.tagName, 'HL');
-      assert.equal(betweenContent.firstChild.className, 'some');
-      assert.equal(betweenContent.childNodes.length, 1);
-      assert.equal(betweenContent.firstChild.childNodes.length, 5);
-      assert.equal(betweenContent.firstChild.textContent,
-          'na💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
-          'quid sit, quod Epicurum');
-
-      assert.isNull(diff.querySelector('.right + .content .some'),
-          'Highlight should be applied only to the left side content.');
-
-      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, ');
-    });
-
-    test('multiline highlight w/ start at end of 1st line', function() {
-      var diff = element.querySelector('#diffTable');
-      var startContent =
-          diff.querySelector('.left.lineNum[data-value="138"] ~ .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 = {
-        getCommentThreadByContentEl: sandbox.stub().returns(commentThread),
-        getContentByLine: sandbox.stub().returns({}),
-        getContentsByLineRange: sandbox.stub().returns([betweenContent]),
-        getLineElByChild: sandbox.stub().returns(
-            {getAttribute: sandbox.stub()}),
-      };
-      element._cachedDiffBuilder = builder;
-      builder.getContentByLine.withArgs(138, 'left').returns(
-          startContent);
-      builder.getContentByLine.withArgs(141, 'left').returns(
-          endContent);
-
-      var expectedStartContentNodes = startContent.childNodes.length;
-
-      // The following should not cause an error.
-      element._applyRangedHighlight(
-          'some', 138, startContent.textContent.length, 141, 28, 'left');
-
-      assert.equal(startContent.childNodes.length, expectedStartContentNodes,
-          'Should not add a highlight to the start content');
-    });
-
-    suite('single line ranges', function() {
-      var diff;
-      var contentText;
-      var commentThread;
-      var builder;
-
-      setup(function() {
-        diff = element.querySelector('#diffTable');
-        var contentTd = diff.querySelector(
-            '.left.lineNum[data-value="140"] ~ .content');
-        contentText = contentTd.querySelector('.contentText');
-        commentThread = diff.querySelector('gr-diff-comment-thread');
-        builder = {
-          getCommentThreadByContentEl: sandbox.stub().returns(commentThread),
-          getContentByLine: sandbox.stub().returns(contentText),
-          getContentsByLineRange: sandbox.stub().returns([]),
-          getLineElByChild: sandbox.stub().returns(
-              {getAttribute: sandbox.stub()}),
-        };
-        element._cachedDiffBuilder = builder;
-      });
-
-      test('whole line range', function() {
-        element._applyRangedHighlight('some', 140, 0, 140, 81, 'left');
-        assert.equal(contentText.childNodes.length, 1);
-        assert.instanceOf(contentText.firstChild, Element);
-        assert.equal(contentText.firstChild.tagName, 'HL');
-        assert.equal(contentText.firstChild.className, 'some');
-        assert.equal(contentText.firstChild.childNodes.length, 5);
-        assert.equal(contentText.firstChild.textContent,
-            'na💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
-            'quid sit, quod Epicurum');
-        var tabs = contentText.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() {
-        element._applyRangedHighlight('some', 140, 1, 140, 80, 'left');
-        assert.instanceOf(contentText.firstChild, Text);
-        assert.equal(contentText.childNodes.length, 3);
-        var hl = contentText.querySelector('hl.some');
-        assert.strictEqual(contentText.firstChild, hl.previousSibling);
-        assert.equal(hl.childNodes.length, 5);
-        assert.equal(contentText.querySelectorAll('span.tab').length, 2);
-        assert.equal(hl.textContent,
-            'a💢ti te, inquit, sumus aliquando otiosum, certe a udiam, ' +
-            'quid sit, quod Epicuru');
-      });
-
-      test('hl inside Text node', function() {
-        // Before: na💢ti
-        //  After: n<hl class="some">a💢t</hl>i
-        element._applyRangedHighlight('some', 140, 1, 140, 4, 'left');
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">a💢t</hl>');
-      });
-
-      test('hl ending over different hl', function() {
-        // Before: na💢ti <hl>te, inquit</hl>,
-        //  After: na💢<hl class="some">ti te</hl><hl class="foo">, inquit</hl>,
-        element._applyRangedHighlight('some', 140, 3, 140, 8, 'left');
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">ti te</hl>');
-        assert.equal(hl.nextSibling.outerHTML,
-            '<hl class="foo">, inquit</hl>');
-      });
-
-      test('hl starting inside different hl', function() {
-        // Before: na💢ti <hl>te, inquit</hl>, sumus
-        //  After: na💢ti <hl class="foo">te, in</hl><hl class="some">quit, ...
-        element._applyRangedHighlight('some', 140, 12, 140, 21, 'left');
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.textContent, 'quit, sum');
-        assert.equal(
-            hl.previousSibling.outerHTML, '<hl class="foo">te, in</hl>');
-      });
-
-      test('hl inside different hl', function() {
-        // Before: na💢ti <hl class="foo">te, inquit</hl>, sumus
-        //  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 = contentText.querySelector('hl.some');
-        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>');
-      });
-
-      test('hl starts and ends in different hls', function() {
-        element._applyRangedHighlight('some', 140, 8, 140, 27, 'left');
-        var hl = contentText.querySelector('hl.some');
-        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>');
-      });
-
-      test('hl over different hl', function() {
-        element._applyRangedHighlight('some', 140, 2, 140, 21, 'left');
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">💢ti te, inquit, sum</hl>');
-        assert.notOk(contentText.querySelector('.foo'));
-      });
-
-      test('hl starting and ending in boundaries', function() {
-        element._applyRangedHighlight('some', 140, 6, 140, 33, 'left');
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.textContent, 'te, inquit, sumus aliquando');
-        assert.notOk(contentText.querySelector('.bar'));
-      });
-
-      test('overlapping hls', function() {
-        element._applyRangedHighlight('some', 140, 1, 140, 3, 'left');
-        element._applyRangedHighlight('some', 140, 2, 140, 4, 'left');
-        assert.equal(contentText.querySelectorAll('hl.some').length, 1);
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">a💢t</hl>');
-      });
-
-      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(contentText.querySelectorAll('hl.some').length, 1);
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">a💢ti te, </hl>');
-        assert.equal(hl.nextSibling.outerHTML, '<hl class="foo">inquit</hl>');
-      });
-
-      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(contentText.querySelectorAll('hl.some').length, 1);
-        var hl = contentText.querySelector('hl.some');
-        assert.equal(hl.outerHTML, '<hl class="some">na💢ti</hl>');
-        assert.strictEqual(contentText.firstChild, hl);
-      });
-
-      test('splitting hl containing a tab', function() {
-        element._applyRangedHighlight('some', 140, 63, 140, 72, 'left');
-        assert.equal(contentText.querySelector('hl.some').textContent,
-            'sit, quod');
-        element._applyRangedHighlight('another', 140, 66, 140, 81, 'left');
-        assert.equal(contentText.querySelector('hl.another').textContent,
-            ', quod Epicurum');
-      });
-    });
-
-    test('_applyAllHighlights', function() {
-      element.comments = {
-        left: [
-          {
-            range: {
-              start_line: 3,
-              start_character: 14,
-              end_line: 10,
-              end_character: 24,
-            },
-          },
-        ],
-        right: [
-          {
-            range: {
-              start_line: 320,
-              start_character: 200,
-              end_line: 1024,
-              end_character: 768,
-            },
-          },
-        ],
-      };
-      sandbox.stub(element, '_applyRangedHighlight');
-      element._applyAllHighlights();
-      sinon.assert.calledWith(element._applyRangedHighlight,
-          'range', 3, 14, 10, 24, 'left');
-      sinon.assert.calledWith(element._applyRangedHighlight,
-          'range', 320, 200, 1024, 768, 'right');
-    });
-
-    test('apply comment ranges on render', function() {
-      sandbox.stub(element, '_applyAllHighlights');
-      element.fire('render');
-      assert.isTrue(element._applyAllHighlights.called);
-    });
-
-    test('apply comment ranges on context expand', function() {
-      sandbox.stub(element, '_applyAllHighlights');
-      element.fire('show-context');
-      assert.isTrue(element._applyAllHighlights.called);
-    });
-
     suite('selection', function() {
       var diff;
       var builder;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 3055b511..2e7fd56 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -147,9 +147,10 @@
         <gr-diff-highlight
             id="highlights"
             logged-in="[[_loggedIn]]"
-            comments="[[_comments]]">
+            comments="{{_comments}}">
           <gr-diff-builder
               id="diffBuilder"
+              comments="[[_comments]]"
               view-mode="[[viewMode]]"
               is-image-diff="[[isImageDiff]]"
               base-image="[[_baseImage]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
new file mode 100644
index 0000000..53ce0e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.html
@@ -0,0 +1,20 @@
+<!--
+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-ranged-comment-layer">
+  <script src="gr-ranged-comment-layer.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
new file mode 100644
index 0000000..b1c6bf7
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -0,0 +1,186 @@
+// 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 HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
+  var SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+
+  var RANGE_HIGHLIGHT = 'range';
+  var HOVER_HIGHLIGHT = 'rangeHighlight';
+
+  Polymer({
+    is: 'gr-ranged-comment-layer',
+
+    properties: {
+      comments: Object,
+      _listeners: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _commentMap: {
+        type: Object,
+        value: function() { return {left: [], right: []}; },
+      }
+    },
+
+    observers: [
+      '_handleCommentChange(comments.*)',
+    ],
+
+    /**
+     * Layer method to add annotations to a line.
+     * @param {HTMLElement} el The DIV.contentText element to apply the
+     *     annotation to.
+     * @param {GrDiffLine} line The line object.
+     * @param {Object} GrAnnotation The annotation library.
+     */
+    annotate: function(el, line, GrAnnotation) {
+      var ranges = [];
+      if (line.type === GrDiffLine.Type.REMOVE || (
+          line.type === GrDiffLine.Type.BOTH &&
+          el.getAttribute('data-side') !== 'right')) {
+        ranges = ranges.concat(this._getRangesForLine(line, 'left'));
+      }
+      if (line.type === GrDiffLine.Type.ADD || (
+          line.type === GrDiffLine.Type.BOTH &&
+          el.getAttribute('data-side') !== 'left')) {
+        ranges = ranges.concat(this._getRangesForLine(line, 'right'));
+      }
+
+      ranges.forEach(function(range) {
+        GrAnnotation.annotateElement(el, range.start,
+            range.end - range.start,
+            range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
+      });
+    },
+
+    /**
+     * Register a listener for layer updates.
+     * @param {Function<Number, Number, String>} fn The update handler function.
+     *     Should accept as arguments the line numbers for the start and end of
+     *     the update and the side as a string.
+     */
+    addListener: function(fn) {
+      this._listeners.push(fn);
+    },
+
+    /**
+     * Notify Layer listeners of changes to annotations.
+     * @param {Number} start The line where the update starts.
+     * @param {Number} end The line where the update ends.
+     * @param {String} side The side of the update. ('left' or 'right')
+     */
+    _notifyUpdateRange: function(start, end, side) {
+      this._listeners.forEach(function(listener) {
+        listener(start, end, side);
+      });
+    },
+
+    /**
+     * Handle change in the comments by updating the comment maps and by
+     * emitting appropriate update notifications.
+     * @param {Object} record The change record.
+     */
+    _handleCommentChange: function(record) {
+      if (!record.path) { return; }
+
+      // If the entire set of comments was changed.
+      if (record.path === 'comments') {
+        this._commentMap.left = this._computeCommentMap(this.comments.left);
+        this._commentMap.right = this._computeCommentMap(this.comments.right);
+        return;
+      }
+
+      // If the change only changed the `hovering` property of a comment.
+      var match = record.path.match(HOVER_PATH_PATTERN);
+      if (match) {
+        var side = match[1];
+        var index = match[2];
+        var comment = this.comments[side][index];
+        if (comment && comment.range) {
+          this._commentMap[side] = this._computeCommentMap(this.comments[side]);
+          this._notifyUpdateRange(
+              comment.range.start_line, comment.range.end_line, side);
+        }
+        return;
+      }
+
+      // If comments were spliced in or out.
+      match = record.path.match(SPLICE_PATH_PATTERN);
+      if (match) {
+        var side = match[1];
+        this._commentMap[side] = this._computeCommentMap(this.comments[side]);
+        this._handleCommentSplice(record.value, side);
+      }
+    },
+
+    /**
+     * Take a list of comments and return a sparse list mapping line numbers to
+     * partial ranges. Uses an end-character-index of -1 to indicate the end of
+     * the line.
+     * @param {Array<Object>} commentList The list of comments.
+     * @return {Object} The sparse list.
+     */
+    _computeCommentMap: function(commentList) {
+      var result = {};
+      commentList.forEach(function(comment) {
+        if (!comment.range) { return; }
+        var range = comment.range;
+        for (var line = range.start_line; line <= range.end_line; line++) {
+          if (!result[line]) { result[line] = []; }
+          result[line].push({
+            comment: comment,
+            start: line === range.start_line ? range.start_character : 0,
+            end: line === range.end_line ? range.end_character : -1,
+          });
+        }
+      });
+      return result;
+    },
+
+    /**
+     * Translate a splice record into range update notifications.
+     */
+    _handleCommentSplice: function(record, side) {
+      if (!record || !record.indexSplices) { return; }
+      record.indexSplices.forEach(function(splice) {
+        var ranges = splice.removed.length ?
+          splice.removed.map(function(c) { return c.range; }) :
+          [splice.object[splice.index].range];
+        ranges.forEach(function(range) {
+          if (!range) { return; }
+          this._notifyUpdateRange(range.start_line, range.end_line, side);
+        }.bind(this));
+      }.bind(this));
+    },
+
+    _getRangesForLine: function(line, side) {
+      var lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
+      var ranges = this.get(['_commentMap', side, lineNum]) || [];
+      return ranges
+          .map(function(range) {
+            return {
+              start: range.start,
+              end: range.end === -1 ? line.text.length : range.end,
+              hovering: !!range.comment.__hovering,
+            };
+          })
+          .sort(function(a, b) {
+            // Sort the ranges so that hovering highlights are on top.
+            return a.hovering && !b.hovering ? 1 : 0;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
new file mode 100644
index 0000000..22ad439
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -0,0 +1,322 @@
+<!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-ranged-comment-layer</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>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-ranged-comment-layer.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-ranged-comment-layer></gr-ranged-comment-layer>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-ranged-comment-layer', function() {
+    var element;
+    var sandbox;
+
+    setup(function() {
+      var initialComments = {
+        left: [
+          {
+            id: '12345',
+            line: 39,
+            message: 'range comment',
+            range: {
+              end_character: 9,
+              end_line: 39,
+              start_character: 6,
+              start_line: 36,
+            },
+          }, {
+            id: '23456',
+            line: 100,
+            message: 'non range comment',
+          },
+        ],
+        right: [
+          {
+            id: '34567',
+            line: 10,
+            message: 'range comment',
+            range: {
+              end_character: 22,
+              end_line: 12,
+              start_character: 10,
+              start_line: 10,
+            },
+          }, {
+            id: '45678',
+            line: 100,
+            message: 'single line range comment',
+            range: {
+              end_character: 15,
+              end_line: 100,
+              start_character: 5,
+              start_line: 100,
+            },
+          },
+        ],
+      };
+
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.comments = initialComments;
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    suite('annotate', function() {
+      var GrAnnotation;
+      var el;
+      var line;
+
+      setup(function() {
+        GrAnnotation = {annotateElement: sinon.stub()};
+        el = document.createElement('div');
+        el.setAttribute('data-side', 'left');
+        line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
+      });
+
+      test('type=Remove no-comment', function() {
+        line.type = GrDiffLine.Type.REMOVE;
+        line.beforeNumber = 40;
+
+        element.annotate(el, line, GrAnnotation);
+
+        assert.isFalse(GrAnnotation.annotateElement.called);
+      });
+
+      test('type=Remove has-comment', function() {
+        line.type = GrDiffLine.Type.REMOVE;
+        line.beforeNumber = 36;
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line, GrAnnotation);
+
+        assert.isTrue(GrAnnotation.annotateElement.called);
+        var lastCall = GrAnnotation.annotateElement.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'range');
+      });
+
+      test('type=Remove has-comment hovering', function() {
+        line.type = GrDiffLine.Type.REMOVE;
+        line.beforeNumber = 36;
+        element.set(['comments', 'left', 0, '__hovering'], true);
+
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line, GrAnnotation);
+
+        assert.isTrue(GrAnnotation.annotateElement.called);
+        var lastCall = GrAnnotation.annotateElement.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'rangeHighlight');
+      });
+
+      test('type=Both has-comment', function() {
+        line.type = GrDiffLine.Type.BOTH;
+        line.beforeNumber = 36;
+
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line, GrAnnotation);
+
+        assert.isTrue(GrAnnotation.annotateElement.called);
+        var lastCall = GrAnnotation.annotateElement.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'range');
+      });
+
+      test('type=Both has-comment off side', function() {
+        line.type = GrDiffLine.Type.BOTH;
+        line.beforeNumber = 36;
+        el.setAttribute('data-side', 'right');
+
+        var expectedStart = 6;
+        var expectedLength = line.text.length - expectedStart;
+
+        element.annotate(el, line, GrAnnotation);
+
+        assert.isFalse(GrAnnotation.annotateElement.called);
+      });
+
+      test('type=Add has-comment', function() {
+        line.type = GrDiffLine.Type.ADD;
+        line.afterNumber = 12;
+        el.setAttribute('data-side', 'right');
+
+        var expectedStart = 0;
+        var expectedLength = 22;
+
+        element.annotate(el, line, GrAnnotation);
+
+        assert.isTrue(GrAnnotation.annotateElement.called);
+        var lastCall = GrAnnotation.annotateElement.lastCall;
+        assert.equal(lastCall.args[0], el);
+        assert.equal(lastCall.args[1], expectedStart);
+        assert.equal(lastCall.args[2], expectedLength);
+        assert.equal(lastCall.args[3], 'range');
+      });
+    });
+
+    test('_handleCommentChange overwrite', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+
+      element.set('comments', {left: [], right: []});
+
+      assert.isTrue(handlerSpy.called);
+      assert.equal(mapSpy.callCount, 2);
+
+      assert.equal(Object.keys(element._commentMap.left).length, 0);
+      assert.equal(Object.keys(element._commentMap.right).length, 0);
+    });
+
+    test('_handleCommentChange hovering', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+      var notifyStub = sinon.stub();
+      element.addListener(notifyStub);
+
+      element.set(['comments', 'right', 0, '__hovering'], true);
+
+      assert.isTrue(handlerSpy.called);
+      assert.isTrue(mapSpy.called);
+
+      assert.isTrue(notifyStub.called);
+      var lastCall = notifyStub.lastCall;
+      assert.equal(lastCall.args[0], 10);
+      assert.equal(lastCall.args[1], 12);
+      assert.equal(lastCall.args[2], 'right');
+    });
+
+    test('_handleCommentChange splice out', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+      var notifyStub = sinon.stub();
+      element.addListener(notifyStub);
+
+      element.splice('comments.right', 0, 1);
+
+      assert.isTrue(handlerSpy.called);
+      assert.isTrue(mapSpy.called);
+
+      assert.isTrue(notifyStub.called);
+      var lastCall = notifyStub.lastCall;
+      assert.equal(lastCall.args[0], 10);
+      assert.equal(lastCall.args[1], 12);
+      assert.equal(lastCall.args[2], 'right');
+    });
+
+    test('_handleCommentChange splice in', function() {
+      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+      var notifyStub = sinon.stub();
+      element.addListener(notifyStub);
+
+      element.splice('comments.left', element.comments.left.length, 0, {
+        id: '56123',
+        line: 250,
+        message: 'new range comment',
+        range: {
+          end_character: 15,
+          end_line: 275,
+          start_character: 5,
+          start_line: 250,
+        },
+      });
+
+      assert.isTrue(handlerSpy.called);
+      assert.isTrue(mapSpy.called);
+
+      assert.isTrue(notifyStub.called);
+      var lastCall = notifyStub.lastCall;
+      assert.equal(lastCall.args[0], 250);
+      assert.equal(lastCall.args[1], 275);
+      assert.equal(lastCall.args[2], 'left');
+    });
+
+    test('_computeCommentMap creates maps correctly', function() {
+      // There is only one ranged comment on the left, but it spans ll.36-39.
+      var leftKeys = [];
+      for (var i = 36; i <= 39; i++) { leftKeys.push('' + i); }
+      assert.deepEqual(Object.keys(element._commentMap.left).sort(),
+          leftKeys.sort());
+
+      assert.equal(element._commentMap.left[36].length, 1);
+      assert.equal(element._commentMap.left[36][0].start, 6);
+      assert.equal(element._commentMap.left[36][0].end, -1);
+
+      assert.equal(element._commentMap.left[37].length, 1);
+      assert.equal(element._commentMap.left[37][0].start, 0);
+      assert.equal(element._commentMap.left[37][0].end, -1);
+
+      assert.equal(element._commentMap.left[38].length, 1);
+      assert.equal(element._commentMap.left[38][0].start, 0);
+      assert.equal(element._commentMap.left[38][0].end, -1);
+
+      assert.equal(element._commentMap.left[39].length, 1);
+      assert.equal(element._commentMap.left[39][0].start, 0);
+      assert.equal(element._commentMap.left[39][0].end, 9);
+
+      // The right has two ranged comments, one spanning ll.10-12 and the other
+      // on line 100.
+      var rightKeys = [];
+      for (i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+      rightKeys.push('100');
+      assert.deepEqual(Object.keys(element._commentMap.right).sort(),
+          rightKeys.sort());
+
+      assert.equal(element._commentMap.right[10].length, 1);
+      assert.equal(element._commentMap.right[10][0].start, 10);
+      assert.equal(element._commentMap.right[10][0].end, -1);
+
+      assert.equal(element._commentMap.right[11].length, 1);
+      assert.equal(element._commentMap.right[11][0].start, 0);
+      assert.equal(element._commentMap.right[11][0].end, -1);
+
+      assert.equal(element._commentMap.right[12].length, 1);
+      assert.equal(element._commentMap.right[12][0].start, 0);
+      assert.equal(element._commentMap.right[12][0].end, 22);
+
+      assert.equal(element._commentMap.right[100].length, 1);
+      assert.equal(element._commentMap.right[100][0].start, 5);
+      assert.equal(element._commentMap.right[100][0].end, 15);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index f5d7ee7..45bf8fe 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -38,7 +38,7 @@
 
     _handleRemoveTap: function(e) {
       e.preventDefault();
-      this.fire('remove', {account: this.account}, {bubbles: false});
+      this.fire('remove', {account: this.account});
     },
 
     _getHasAvatars: function() {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index b7f9715..f136907 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -23,6 +23,9 @@
       :host {
         display: inline;
       }
+      :host::after {
+        content: var(--account-label-suffix);
+      }
       gr-avatar {
         height: 1.3em;
         width: 1.3em;
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index c7438df..6fd57b5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -23,6 +23,11 @@
       input {
         font-size: 1em;
       }
+      input.borderless,
+      input.borderless:focus {
+        border: none;
+        outline: none;
+      }
       #suggestions {
         background-color: #fff;
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
@@ -42,6 +47,7 @@
     </style>
     <input
         id="input"
+        class$="[[_computeClass(borderless)]]"
         is="iron-input"
         disabled$="[[disabled]]"
         bind-value="{{text}}"
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 6ab5fa2..23fa800 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -59,6 +59,7 @@
         value: 1,
       },
 
+      borderless: Boolean,
       disabled: Boolean,
 
       text: {
@@ -96,6 +97,10 @@
       this.unlisten(document.body, 'click', '_handleBodyClick');
     },
 
+    get focusStart() {
+      return this.$.input;
+    },
+
     focus: function() {
       this.$.input.focus();
     },
@@ -136,6 +141,10 @@
       return !suggestions.length;
     },
 
+    _computeClass: function(borderless) {
+      return borderless ? 'borderless' : '';
+    },
+
     _getSuggestionElems: function() {
       Polymer.dom.flush();
       return this.$.suggestions.querySelectorAll('li');
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 755dc93..c044e4f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -208,5 +208,11 @@
 
       assert.isTrue(queryStub.called);
     });
+
+    test('_computeClass respects border property', function() {
+      assert.equal(element._computeClass(), '');
+      assert.equal(element._computeClass(false), '');
+      assert.equal(element._computeClass(true), 'borderless');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
similarity index 100%
rename from polygerrit-ui/app/elements/diff/gr-diff-cursor/mock-diff-response_test.html
rename to polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index feb4f49..a7337f4 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -27,6 +27,8 @@
   [
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list/gr-change-list_test.html',
+    'change/gr-account-entry/gr-account-entry_test.html',
+    'change/gr-account-list/gr-account-list_test.html',
     'change/gr-change-actions/gr-change-actions_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
     'change/gr-change-view/gr-change-view_test.html',
@@ -56,6 +58,7 @@
     'diff/gr-diff/gr-diff-group_test.html',
     'diff/gr-diff/gr-diff_test.html',
     'diff/gr-patch-range-select/gr-patch-range-select_test.html',
+    'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',