Merge "Add REST endpoint to reindex a single account"
diff --git a/Documentation/config-plugins.txt b/Documentation/config-plugins.txt
index 4ac8d62..3a55b48 100644
--- a/Documentation/config-plugins.txt
+++ b/Documentation/config-plugins.txt
@@ -164,24 +164,24 @@
 Documentation]
 
 [[avatars-external]]
-=== avatars/external
+=== avatars-external
 
 This plugin allows to use an external url to load the avatar images
 from.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars/external[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-external[
 Project] |
-link:https://gerrit.googlesource.com/plugins/avatars/external/+doc/master/src/main/resources/Documentation/about.md[
+link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/about.md[
 Documentation] |
-link:https://gerrit.googlesource.com/plugins/avatars/external/+doc/master/src/main/resources/Documentation/config.md[
+link:https://gerrit.googlesource.com/plugins/avatars-external/+doc/master/src/main/resources/Documentation/config.md[
 Configuration]
 
 [[avatars-gravatar]]
-=== avatars/gravatar
+=== avatars-gravatar
 
 Plugin to display user icons from Gravatar.
 
-link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars/gravatar[
+link:https://gerrit-review.googlesource.com/#/admin/projects/plugins/avatars-gravatar[
 Project]
 
 [[branch-network]]
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index e7557fd..5a6c229 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -2420,6 +2420,65 @@
   }
 
   @Test
+  public void checkLabelsForMergedChangeWithNonAuthorCodeReview()
+      throws Exception {
+    // Configure Non-Author-Code-Review
+    RevCommit oldHead = getRemoteHead();
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+    testRepo.reset("config");
+    PushOneCommit push2 = pushFactory.create(db, admin.getIdent(), testRepo,
+        "Configure Non-Author-Code-Review",
+        "rules.pl",
+        "submit_rule(S) :-\n"
+            + "  gerrit:default_submit(X),\n"
+            + "  X =.. [submit | Ls],\n"
+            + "  add_non_author_approval(Ls, R),\n"
+            + "  S =.. [submit | R].\n"
+            + "\n"
+            + "add_non_author_approval(S1, S2) :-\n"
+            + "  gerrit:commit_author(A),\n"
+            + "  gerrit:commit_label(label('Code-Review', 2), R),\n"
+            + "  R \\= A, !,\n"
+            + "  S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
+            + "add_non_author_approval(S1,"
+            + " [label('Non-Author-Code-Review', need(_)) | S1]).");
+    push2.to(RefNames.REFS_CONFIG);
+    testRepo.reset(oldHead);
+
+    // Allow user to approve
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    AccountGroup.UUID registeredUsers =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    String heads = RefNames.REFS_HEADS + "*";
+    Util.allow(cfg, Permission.forLabel(Util.codeReview().getName()), -2, 2,
+        registeredUsers, heads);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .review(ReviewInput.approve());
+
+    setApiUser(admin);
+    gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .submit();
+
+    ChangeInfo change = gApi.changes()
+        .id(r.getChangeId())
+        .get();
+    assertThat(change.status).isEqualTo(ChangeStatus.MERGED);
+    assertThat(change.labels.keySet()).containsExactly("Code-Review",
+        "Non-Author-Code-Review");
+    assertThat(change.permittedLabels.keySet()).containsExactly("Code-Review");
+    assertPermitted(change, "Code-Review", 0, 1, 2);
+  }
+
+  @Test
   public void checkLabelsForAutoClosedChange() throws Exception {
     PushOneCommit.Result r = createChange();
 
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index e2883ffc..762b868 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -112,7 +112,7 @@
           mappingBuilder.addString(name);
         } else {
           throw new IllegalArgumentException(
-              "Unsupported filed type " + fieldType.getName());
+              "Unsupported field type " + fieldType.getName());
         }
       }
       MappingProperties mapping = mappingBuilder.build();
@@ -151,7 +151,11 @@
 
   private static <T> List<T> decodeProtos(JsonObject doc, String fieldName,
       ProtobufCodec<T> codec) {
-    return FluentIterable.from(doc.getAsJsonArray(fieldName))
+    JsonArray field = doc.getAsJsonArray(fieldName);
+    if (field == null) {
+      return null;
+    }
+    return FluentIterable.from(field)
         .transform(i -> codec.decode(decodeBase64(i.toString())))
         .toList();
   }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 629ad97..a545cad 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -163,6 +163,8 @@
 
   /**
    * Delete the assignee of a change.
+   *
+   * @return the assignee that was deleted, or null if there was no assignee.
    */
   AccountInfo deleteAssignee() throws RestApiException;
 
diff --git a/gerrit-gwtexpui/BUILD b/gerrit-gwtexpui/BUILD
index d74fc8b..610a10b 100644
--- a/gerrit-gwtexpui/BUILD
+++ b/gerrit-gwtexpui/BUILD
@@ -16,7 +16,7 @@
   deps = [
     ':SafeHtml',
     ':UserAgent',
-    '//lib/gwt:user',
+    '//lib/gwt:user-neverlink',
   ],
   visibility = ['//visibility:public'],
   data = [
diff --git a/gerrit-gwtui-common/BUILD b/gerrit-gwtui-common/BUILD
index c6ad882..ac31856 100644
--- a/gerrit-gwtui-common/BUILD
+++ b/gerrit-gwtui-common/BUILD
@@ -10,7 +10,7 @@
   '//gerrit-gwtexpui:SafeHtml',
   '//gerrit-gwtexpui:UserAgent',
 ]
-DEPS = ['//lib/gwt:user']
+DEPS = ['//lib/gwt:user-neverlink']
 SRC = 'src/main/java/com/google/gerrit/'
 
 gwt_module(
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
index 18bc038..01d9385 100644
--- a/gerrit-plugin-gwtui/BUILD
+++ b/gerrit-plugin-gwtui/BUILD
@@ -18,7 +18,21 @@
   srcs = SRCS,
   resources = glob(['src/main/**/*']),
   exported_deps = ['//gerrit-gwtui-common:client-lib'],
-  deps = DEPS + ['//lib/gwt:dev'],
+  deps = DEPS + [
+    '//gerrit-common:libclient-src.jar',
+    '//gerrit-extension-api:libclient-src.jar',
+    '//gerrit-gwtexpui:libClippy-src.jar',
+    '//gerrit-gwtexpui:libGlobalKey-src.jar',
+    '//gerrit-gwtexpui:libProgress-src.jar',
+    '//gerrit-gwtexpui:libSafeHtml-src.jar',
+    '//gerrit-gwtexpui:libUserAgent-src.jar',
+    '//gerrit-gwtui-common:libclient-src.jar',
+    '//gerrit-patch-jgit:libclient-src.jar',
+    '//gerrit-patch-jgit:libEdit-src.jar',
+    '//gerrit-prettify:libclient-src.jar',
+    '//gerrit-reviewdb:libclient-src.jar',
+    '//lib/gwt:dev-neverlink',
+  ],
 )
 
 java_library2(
diff --git a/gerrit-prettify/BUILD b/gerrit-prettify/BUILD
index b8d4dd6..c21a6f4 100644
--- a/gerrit-prettify/BUILD
+++ b/gerrit-prettify/BUILD
@@ -8,7 +8,7 @@
     SRC + 'common/**/*.java',
   ]),
   gwt_xml = SRC + 'PrettyFormatter.gwt.xml',
-  deps = ['//lib/gwt:user'],
+  deps = ['//lib/gwt:user-neverlink'],
   exported_deps = [
     '//gerrit-extension-api:client',
     '//gerrit-gwtexpui:SafeHtml',
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
index f63c618..78aef91 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
@@ -67,7 +67,7 @@
 
   public AccountSshKey(final AccountSshKey.Id i, final String pub) {
     id = i;
-    sshPublicKey = pub;
+    sshPublicKey = pub.replace("\n", "").replace("\r", "");
     valid = id.isValid();
   }
 
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
index 139d360..07c00b9 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountSshKeyTest.java
@@ -25,6 +25,12 @@
       + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T"
       + "w== john.doe@example.com";
 
+  private static final String KEY_WITH_NEWLINES =
+      "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgug5VyMXQGnem2H1KVC4/HcRcD4zzBqS\n"
+      + "uJBRWVonSSoz3RoAZ7bWXCVVGwchtXwUURD689wFYdiPecOrWOUgeeyRq754YWRhU+W28\n"
+      + "vf8IZixgjCmiBhaL2gt3wff6pP+NXJpTSA4aeWE5DfNK5tZlxlSxqkKOS8JRSUeNQov5T\n"
+      + "w== john.doe@example.com";
+
   private final Account.Id accountId = new Account.Id(1);
 
   @Test
@@ -47,4 +53,14 @@
     assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
     assertThat(key.getComment()).isEqualTo(KEY.split(" ")[2]);
   }
+
+  @Test
+  public void testKeyWithNewLines() throws Exception {
+    AccountSshKey key = new AccountSshKey(
+        new AccountSshKey.Id(accountId, 1), KEY_WITH_NEWLINES);
+    assertThat(key.getSshPublicKey()).isEqualTo(KEY);
+    assertThat(key.getAlgorithm()).isEqualTo(KEY.split(" ")[0]);
+    assertThat(key.getEncodedKey()).isEqualTo(KEY.split(" ")[1]);
+    assertThat(key.getComment()).isEqualTo(KEY.split(" ")[2]);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index 8f25e43..cc6f5db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,18 +14,21 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Iterables;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -61,6 +64,8 @@
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.SortedSet;
@@ -99,6 +104,25 @@
     }
   }
 
+  @AutoValue
+  public abstract static class StarRef {
+    private static final StarRef MISSING =
+        new AutoValue_StarredChangesUtil_StarRef(null, ImmutableSortedSet.of());
+
+    private static StarRef create(Ref ref, Iterable<String> labels) {
+      return new AutoValue_StarredChangesUtil_StarRef(
+          checkNotNull(ref),
+          ImmutableSortedSet.copyOf(labels));
+    }
+
+    @Nullable public abstract Ref ref();
+    public abstract ImmutableSortedSet<String> labels();
+
+    public ObjectId objectId() {
+      return ref() != null ? ref().getObjectId() : ObjectId.zeroId();
+    }
+  }
+
   public static class IllegalLabelException extends IllegalArgumentException {
     private static final long serialVersionUID = 1L;
 
@@ -153,8 +177,8 @@
   public ImmutableSortedSet<String> getLabels(Account.Id accountId,
       Change.Id changeId) throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return ImmutableSortedSet.copyOf(
-          readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
+      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId))
+          .labels();
     } catch (IOException e) {
       throw new OrmException(
           String.format("Reading stars from change %d for account %d failed",
@@ -167,9 +191,9 @@
       Set<String> labelsToRemove) throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
-      ObjectId oldObjectId = getObjectId(repo, refName);
+      StarRef old = readLabels(repo, refName);
 
-      SortedSet<String> labels = readLabels(repo, oldObjectId);
+      Set<String> labels = new HashSet<>(old.labels());
       if (labelsToAdd != null) {
         labels.addAll(labelsToAdd);
       }
@@ -178,10 +202,10 @@
       }
 
       if (labels.isEmpty()) {
-        deleteRef(repo, refName, oldObjectId);
+        deleteRef(repo, refName, old.objectId());
       } else {
         checkMutuallyExclusiveLabels(labels);
-        updateLabels(repo, refName, oldObjectId, labels);
+        updateLabels(repo, refName, old.objectId(), labels);
       }
 
       indexer.index(dbProvider.get(), project, changeId);
@@ -222,11 +246,11 @@
     }
   }
 
-  public ImmutableMultimap<Account.Id, String> byChange(Change.Id changeId)
+  public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId)
       throws OrmException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ImmutableMultimap.Builder<Account.Id, String> builder =
-          new ImmutableMultimap.Builder<>();
+      ImmutableMap.Builder<Account.Id, StarRef> builder =
+          ImmutableMap.builder();
       for (String refPart : getRefNames(repo,
           RefNames.refsStarredChangesPrefix(changeId))) {
         Integer id = Ints.tryParse(refPart);
@@ -234,7 +258,7 @@
           continue;
         }
         Account.Id accountId = new Account.Id(id);
-        builder.putAll(accountId,
+        builder.put(accountId,
             readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
       }
       return builder.build();
@@ -280,7 +304,7 @@
       Account.Id accountId, String label) {
     try {
       return readLabels(repo,
-          RefNames.refsStarredChanges(changeId, accountId))
+          RefNames.refsStarredChanges(changeId, accountId)).labels()
               .contains(label);
     } catch (IOException e) {
       log.error(String.format(
@@ -311,8 +335,8 @@
 
   public ObjectId getObjectId(Account.Id accountId, Change.Id changeId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getObjectId(repo,
-          RefNames.refsStarredChanges(changeId, accountId));
+      Ref ref = repo.exactRef(RefNames.refsStarredChanges(changeId, accountId));
+      return ref != null ? ref.getObjectId() : ObjectId.zeroId();
     } catch (IOException e) {
       log.error(String.format(
           "Getting star object ID for account %d on change %d failed",
@@ -321,39 +345,33 @@
     }
   }
 
-  private static ObjectId getObjectId(Repository repo, String refName)
+  private static StarRef readLabels(Repository repo, String refName)
       throws IOException {
     Ref ref = repo.exactRef(refName);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  private static SortedSet<String> readLabels(Repository repo, String refName)
-      throws IOException {
-    return readLabels(repo, getObjectId(repo, refName));
-  }
-
-  private static TreeSet<String> readLabels(Repository repo, ObjectId id)
-      throws IOException {
-    if (ObjectId.zeroId().equals(id)) {
-      return new TreeSet<>();
+    if (ref == null) {
+      return StarRef.MISSING;
     }
 
     try (ObjectReader reader = repo.newObjectReader()) {
-      ObjectLoader obj = reader.open(id, Constants.OBJ_BLOB);
-      TreeSet<String> labels = new TreeSet<>();
-      Iterables.addAll(labels,
+      ObjectLoader obj = reader.open(ref.getObjectId(), Constants.OBJ_BLOB);
+      return StarRef.create(
+          ref,
           Splitter.on(CharMatcher.whitespace()).omitEmptyStrings()
               .split(new String(obj.getCachedBytes(Integer.MAX_VALUE), UTF_8)));
-      return labels;
     }
   }
 
-  public static ObjectId writeLabels(Repository repo, SortedSet<String> labels)
+  public static ObjectId writeLabels(Repository repo, Collection<String> labels)
       throws IOException {
     validateLabels(labels);
     try (ObjectInserter oi = repo.newObjectInserter()) {
-      ObjectId id = oi.insert(Constants.OBJ_BLOB,
-          Joiner.on("\n").join(labels).getBytes(UTF_8));
+      ObjectId id = oi.insert(
+          Constants.OBJ_BLOB,
+          labels.stream()
+              .sorted()
+              .distinct()
+              .collect(joining("\n"))
+              .getBytes(UTF_8));
       oi.flush();
       return id;
     }
@@ -366,7 +384,7 @@
     }
   }
 
-  private static void validateLabels(Set<String> labels) {
+  private static void validateLabels(Collection<String> labels) {
     if (labels == null) {
       return;
     }
@@ -383,7 +401,7 @@
   }
 
   private void updateLabels(Repository repo, String refName,
-      ObjectId oldObjectId, SortedSet<String> labels)
+      ObjectId oldObjectId, Collection<String> labels)
           throws IOException, OrmException {
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
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 3518ccd..3f3afa5 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
@@ -36,7 +36,6 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
-
 import static java.util.stream.Collectors.toList;
 
 import com.google.auto.value.AutoValue;
@@ -822,8 +821,10 @@
     }
 
     if (detailed) {
-      labels.entrySet().stream().forEach(
-          e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+      labels.entrySet().stream()
+          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()),
+              e.getValue()));
     }
 
     for (Account.Id accountId : allUsers) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
index f07ee25..c5343e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -76,10 +76,10 @@
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
-      if (op.getDeletedAssignee() == null) {
-        return Response.none();
-      }
-      return Response.ok(AccountJson.toAccountInfo(op.getDeletedAssignee()));
+      Account deletedAssignee = op.getDeletedAssignee();
+      return deletedAssignee == null
+          ? Response.none()
+          : Response.ok(AccountJson.toAccountInfo(deletedAssignee));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
index b8224a8..2a32652 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
@@ -26,13 +26,13 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+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.UpdateException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gwtorm.server.OrmException;
-import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index a7ab082..7ece10f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -238,9 +238,9 @@
    * @throws MergeConflictException the rebase failed due to a merge conflict.
    * @throws IOException the merge failed for another reason.
    */
-  private RevCommit rebaseCommit(RepoContext ctx, RevCommit original,
-      ObjectId base, String commitMessage)
-      throws ResourceConflictException, MergeConflictException, IOException {
+  private RevCommit rebaseCommit(
+      RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+      throws ResourceConflictException, IOException {
     RevCommit parentCommit = original.getParent(0);
 
     if (base.equals(parentCommit)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index 54361cc..795bec8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -84,6 +84,7 @@
 import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 
 /**
@@ -697,7 +698,7 @@
       rw.markStart(mergeTip);
       for (RevCommit c : alreadyAccepted) {
         // If branch was not created by this submit.
-        if (c != mergeTip) {
+        if (!Objects.equals(c, mergeTip)) {
           rw.markUninteresting(c);
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index 5ef548c..f881f2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -153,9 +153,7 @@
    */
   public CheckedFuture<?, IOException> indexAsync(Project.NameKey project,
       Change.Id id) {
-    return executor != null
-        ? submit(new IndexTask(project, id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
+    return submit(new IndexTask(project, id));
   }
 
   /**
@@ -235,9 +233,7 @@
    * @return future for the deleting task.
    */
   public CheckedFuture<?, IOException> deleteAsync(Change.Id id) {
-    return executor != null
-        ? submit(new DeleteTask(id))
-        : Futures.<Object, IOException> immediateCheckedFuture(null);
+    return submit(new DeleteTask(id));
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java
new file mode 100644
index 0000000..9363722
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentFormatter.java
@@ -0,0 +1,188 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.base.Strings.isNullOrEmpty;
+
+import com.google.gerrit.common.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class CommentFormatter {
+  public enum BlockType {
+    LIST,
+    PARAGRAPH,
+    PRE_FORMATTED,
+    QUOTE
+  }
+
+  public static class Block {
+    public BlockType type;
+    public String text;
+    public List<String> items;
+  }
+
+  /**
+   * Take a string of comment text that was written using the wiki-Like format
+   * and emit a list of blocks that can be rendered to block-level HTML. This
+   * method does not escape HTML.
+   *
+   * Adapted from the {@code wikify} method found in:
+   *   com.google.gwtexpui.safehtml.client.SafeHtml
+   *
+   * @param source The raw, unescaped comment in the Gerrit wiki-like format.
+   * @return List of block objects, each with unescaped comment content.
+   */
+  public static List<Block> parse(@Nullable String source) {
+    if (isNullOrEmpty(source)) {
+      return Collections.emptyList();
+    }
+
+    List<Block> result = new ArrayList<>();
+    for (String p : source.split("\n\n")) {
+      if (isQuote(p)) {
+        result.add(makeQuote(p));
+      } else if (isPreFormat(p)) {
+        result.add(makePre(p));
+      } else if (isList(p)) {
+        makeList(p, result);
+      } else if (!p.isEmpty()) {
+        result.add(makeParagraph(p));
+      }
+    }
+    return result;
+  }
+
+  /**
+   * Take a block of comment text that contains a list and potentially
+   * paragraphs (but does not contain blank lines), generate appropriate block
+   * elements and append them to the output list.
+   *
+   * In simple cases, this will generate a single list block. For example, on
+   * the following input.
+   *
+   *    * Item one.
+   *    * Item two.
+   *    * item three.
+   *
+   * However, if the list is adjacent to a paragraph, it will need to also
+   * generate that paragraph. Consider the following input.
+   *
+   *    A bit of text describing the context of the list:
+   *    * List item one.
+   *    * List item two.
+   *    * Et cetera.
+   *
+   * In this case, {@code makeList} generates a paragraph block object
+   * containing the non-bullet-prefixed text, followed by a list block.
+   *
+   * Adapted from the {@code wikifyList} method found in:
+   *   com.google.gwtexpui.safehtml.client.SafeHtml
+   *
+   * @param p The block containing the list (as well as potential paragraphs).
+   * @param out The list of blocks to append to.
+   */
+  private static void makeList(String p, List<Block> out) {
+    Block block = null;
+    StringBuilder textBuilder = null;
+    boolean inList = false;
+    boolean inParagraph = false;
+
+    for (String line : p.split("\n")) {
+      if (line.startsWith("-") || line.startsWith("*")) {
+        // The next line looks like a list item. If not building a list already,
+        // then create one. Remove the list item marker (* or -) from the line.
+        if (!inList) {
+          if (inParagraph) {
+            // Add the finished paragraph block to the result.
+            inParagraph = false;
+            block.text = textBuilder.toString();
+            out.add(block);
+          }
+
+          inList = true;
+          block = new Block();
+          block.type = BlockType.LIST;
+          block.items = new ArrayList<>();
+        }
+        line = line.substring(1).trim();
+
+      } else if (!inList) {
+        // Otherwise, if a list has not yet been started, but the next line does
+        // not look like a list item, then add the line to a paragraph block. If
+        // a paragraph block has not yet been started, then create one.
+        if (!inParagraph) {
+          inParagraph = true;
+          block = new Block();
+          block.type = BlockType.PARAGRAPH;
+          textBuilder = new StringBuilder();
+        } else {
+          textBuilder.append(" ");
+        }
+        textBuilder.append(line);
+        continue;
+      }
+
+      block.items.add(line);
+    }
+
+    if (block != null) {
+      out.add(block);
+    }
+  }
+
+  private static Block makeQuote(String p) {
+    if (p.startsWith("> ")) {
+      p = p.substring(2);
+    } else if (p.startsWith(" > ")) {
+      p = p.substring(3);
+    }
+
+    Block block = new Block();
+    block.type = BlockType.QUOTE;
+    block.text = p.replaceAll("\n\\s?>\\s", "\n").trim();
+    return block;
+  }
+
+  private static Block makePre(String p) {
+    Block block = new Block();
+    block.type = BlockType.PRE_FORMATTED;
+    block.text = p;
+    return block;
+  }
+
+  private static Block makeParagraph(String p) {
+    Block block = new Block();
+    block.type = BlockType.PARAGRAPH;
+    block.text = p;
+    return block;
+  }
+
+  private static boolean isQuote(String p) {
+    return p.startsWith("> ") || p.startsWith(" > ");
+  }
+
+  private static boolean isPreFormat(String p) {
+    return p.startsWith(" ") || p.startsWith("\t")
+        || p.contains("\n ") || p.contains("\n\t");
+  }
+
+  private static boolean isList(String p) {
+    return p.startsWith("- ") || p.startsWith("* ")
+        || p.contains("\n- ") || p.contains("\n* ");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
index 9f72d66..5910aed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -52,6 +52,7 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.stream.Collectors;
 
 /** Send comments, after the author of them hit used Publish Comments in the UI.
  */
@@ -480,6 +481,7 @@
         Map<String, Object> commentData = new HashMap<>();
         commentData.put("lines", getLinesOfComment(comment, group.fileData));
         commentData.put("message", comment.message.trim());
+        commentData.put("messageBlocks", formatComment(comment.message));
 
         // Set the prefix.
         String prefix = getCommentLinePrefix(comment);
@@ -533,6 +535,34 @@
     return commentGroups;
   }
 
+  private List<Map<String, Object>> formatComment(String comment) {
+    return CommentFormatter.parse(comment)
+        .stream()
+        .map(b -> {
+          Map<String, Object> map = new HashMap<>();
+          switch (b.type) {
+            case PARAGRAPH:
+              map.put("type", "paragraph");
+              map.put("text", b.text);
+              break;
+            case PRE_FORMATTED:
+              map.put("type", "pre");
+              map.put("text", b.text);
+              break;
+            case QUOTE:
+              map.put("type", "quote");
+              map.put("text", b.text);
+              break;
+            case LIST:
+              map.put("type", "list");
+              map.put("items", b.items);
+              break;
+          }
+          return map;
+        })
+        .collect(Collectors.toList());
+  }
+
   private Repository getRepository() {
     try {
       return args.server.openRepository(projectState.getProject().getNameKey());
@@ -548,6 +578,7 @@
       soyContext.put("commentFiles", getCommentGroupsTemplateData(repo));
     }
     soyContext.put("commentTimestamp", getCommentTimestamp());
+    soyContext.put("coverLetterBlocks", formatComment(getCoverLetter()));
   }
 
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
index e0545a3..b9871ab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -40,6 +40,10 @@
 public class ListPlugins implements RestReadView<TopLevelResource> {
   private final PluginLoader pluginLoader;
 
+  @Deprecated
+  @Option(name = "--format", usage = "(deprecated) output format")
+  private OutputFormat format = OutputFormat.TEXT;
+
   @Option(name = "--all", aliases = {"-a"}, usage = "List all plugins, including disabled plugins")
   private boolean all;
 
@@ -48,12 +52,23 @@
     this.pluginLoader = pluginLoader;
   }
 
+  public OutputFormat getFormat() {
+    return format;
+  }
+
+  public ListPlugins setFormat(OutputFormat fmt) {
+    this.format = fmt;
+    return this;
+  }
+
   @Override
   public Object apply(TopLevelResource resource) {
+    format = OutputFormat.JSON;
     return display(null);
   }
 
   public JsonElement display(PrintWriter stdout) {
+    Map<String, PluginInfo> output = new TreeMap<>();
     List<Plugin> plugins = Lists.newArrayList(pluginLoader.getPlugins(all));
     Collections.sort(plugins, new Comparator<Plugin>() {
       @Override
@@ -62,24 +77,30 @@
       }
     });
 
-    if (stdout == null) {
-      Map<String, PluginInfo> output = new TreeMap<>();
-      for (Plugin p : plugins) {
-        PluginInfo info = new PluginInfo(p);
+    if (!format.isJson()) {
+      stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
+      stdout.print("-------------------------------------------------------------------------------\n");
+    }
+
+    for (Plugin p : plugins) {
+      PluginInfo info = new PluginInfo(p);
+      if (format.isJson()) {
         output.put(p.getName(), info);
+      } else {
+        stdout.format("%-30s %-10s %-8s %s\n", p.getName(),
+            Strings.nullToEmpty(info.version),
+            p.isDisabled() ? "DISABLED" : "ENABLED",
+            p.getSrcFile().getFileName());
       }
+    }
+
+    if (stdout == null) {
       return OutputFormat.JSON.newGson().toJsonTree(
           output,
           new TypeToken<Map<String, Object>>() {}.getType());
-    }
-    stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
-    stdout.print("-------------------------------------------------------------------------------\n");
-    for (Plugin p : plugins) {
-      PluginInfo info = new PluginInfo(p);
-      stdout.format("%-30s %-10s %-8s %s\n", p.getName(),
-          Strings.nullToEmpty(info.version),
-          p.isDisabled() ? "DISABLED" : "ENABLED",
-          p.getSrcFile().getFileName());
+    } else if (format.isJson()) {
+      format.newGson().toJson(output,
+          new TypeToken<Map<String, PluginInfo>>() {}.getType(), stdout);
       stdout.print('\n');
     }
     stdout.flush();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 21896f2..913cffd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -23,6 +23,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
@@ -51,6 +52,7 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.StarRef;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -352,6 +354,7 @@
   @Deprecated
   private Set<Account.Id> starredByUser;
   private ImmutableMultimap<Account.Id, String> stars;
+  private ImmutableMap<Account.Id, StarRef> starRefs;
   private ReviewerSet reviewers;
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
@@ -1204,7 +1207,12 @@
       if (!lazyLoad) {
         return ImmutableMultimap.of();
       }
-      stars = checkNotNull(starredChangesUtil).byChange(legacyId);
+      ImmutableMultimap.Builder<Account.Id, String> b =
+          ImmutableMultimap.builder();
+      for (Map.Entry<Account.Id, StarRef> e : starRefs().entrySet()) {
+        b.putAll(e.getKey(), e.getValue().labels());
+      }
+      return b.build();
     }
     return stars;
   }
@@ -1213,6 +1221,16 @@
     this.stars = ImmutableMultimap.copyOf(stars);
   }
 
+  public ImmutableMap<Account.Id, StarRef> starRefs() throws OrmException {
+    if (starRefs == null) {
+      if (!lazyLoad) {
+        return ImmutableMap.of();
+      }
+      starRefs = checkNotNull(starredChangesUtil).byChange(legacyId);
+    }
+    return starRefs;
+  }
+
   @AutoValue
   abstract static class ReviewedByEvent {
     private static ReviewedByEvent create(ChangeMessage msg) {
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 21be41b..75492d6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -19,6 +19,7 @@
 /**
  * @param commentFiles
  * @param coverLetter
+ * @param coverLetterBlocks
  * @param email
  * @param fromName
  */
@@ -33,10 +34,6 @@
     padding: 0 10px;
   {/let}
 
-  {let $messageStyle kind="css"}
-    white-space: pre-wrap;
-  {/let}
-
   {let $ulStyle kind="css"}
     list-style: none;
     padding-left: 20px;
@@ -53,7 +50,7 @@
   {/if}
 
   {if $coverLetter}
-    <div style="white-space:pre-wrap">{$coverLetter}</div>
+    {call .WikiFormat}{param content: $coverLetterBlocks /}{/call}
   {/if}
 
   <ul style="{$ulStyle}">
@@ -114,9 +111,7 @@
                 </p>
               {/if}
 
-              <p style="{$messageStyle}">
-                {$comment.message}
-              </p>
+              {call .WikiFormat}{param content: $comment.messageBlocks /}{/call}
             </li>
           {/foreach}
         </ul>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
index 88cd8d0..a256a06 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -41,3 +41,40 @@
   <pre style="{$preStyle}">{$content}</pre>
 {/template}
 
+/**
+ * Take a list of unescaped comment blocks and emit safely escaped HTML to
+ * render it nicely with wiki-like format.
+ *
+ * Each block is a map with a type key. When the type is 'paragraph', 'quote',
+ * or 'pre', it also has a 'text' key that maps to the unescaped text content
+ * for the block. If the type is 'list', the map will have a 'items' key which
+ * maps to list of unescaped list item strings.
+ *
+ * This mechanism encodes as little structure as possible in order to depend on
+ * the Soy autoescape mechanism for all of the content.
+ *
+ * @param content
+ */
+{template .WikiFormat private="true" autoescape="strict" kind="html"}
+  {let $blockquoteStyle kind="css"}
+    border-left: 1px solid #aaa;
+    margin: 10px 0;
+    padding: 0 10px;
+  {/let}
+
+  {foreach $block in $content}
+    {if $block.type == 'paragraph'}
+      <p>{$block.text}</p>
+    {elseif $block.type == 'quote'}
+      <blockquote style="{$blockquoteStyle}">{$block.text}</blockquote>
+    {elseif $block.type == 'pre'}
+      {call .Pre}{param content: $block.text /}{/call}
+    {elseif $block.type == 'list'}
+      <ul>
+        {foreach $item in $block.items}
+          <li>{$item}</li>
+        {/foreach}
+      </ul>
+    {/if}
+  {/foreach}
+{/template}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java
new file mode 100644
index 0000000..8a51c94
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentFormatterTest.java
@@ -0,0 +1,388 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.LIST;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PARAGRAPH;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.PRE_FORMATTED;
+import static com.google.gerrit.server.mail.send.CommentFormatter.BlockType.QUOTE;
+
+import org.junit.Test;
+
+import java.util.List;
+
+public class CommentFormatterTest {
+  private void assertBlock(List<CommentFormatter.Block> list, int index,
+      CommentFormatter.BlockType type, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(type);
+    assertThat(block.text).isEqualTo(text);
+    assertThat(block.items).isNull();
+  }
+
+  private void assertListBlock(List<CommentFormatter.Block> list, int index,
+      int itemIndex, String text) {
+    CommentFormatter.Block block = list.get(index);
+    assertThat(block.type).isEqualTo(LIST);
+    assertThat(block.items.get(itemIndex)).isEqualTo(text);
+    assertThat(block.text).isNull();
+  }
+
+  @Test
+  public void testParseNullAsEmpty() {
+    assertThat(CommentFormatter.parse(null)).isEmpty();
+  }
+
+  @Test
+  public void testParseEmpty() {
+    assertThat(CommentFormatter.parse("")).isEmpty();
+  }
+
+  @Test
+  public void testParseSimple() {
+    String comment = "Para1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void testParseMultilinePara() {
+    String comment = "Para 1\nStill para 1";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PARAGRAPH, comment);
+  }
+
+  @Test
+  public void testParseParaBreak() {
+    String comment = "Para 1\n\nPara 2\n\nPara 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+    assertBlock(result, 2, PARAGRAPH, "Para 3");
+  }
+
+  @Test
+  public void testParseQuote() {
+    String comment = "> Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, QUOTE, "Quote text");
+  }
+
+  @Test
+  public void testParseExcludesEmpty() {
+    String comment = "Para 1\n\n\n\nPara 2";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "Para 1");
+    assertBlock(result, 1, PARAGRAPH, "Para 2");
+  }
+
+  @Test
+  public void testParseQuoteLeadSpace() {
+    String comment = " > Quote text";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, QUOTE, "Quote text");
+  }
+
+  @Test
+  public void testParseMultiLineQuote() {
+    String comment = "> Quote line 1\n> Quote line 2\n > Quote line 3\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, QUOTE, "Quote line 1\nQuote line 2\nQuote line 3");
+  }
+
+  @Test
+  public void testParsePre() {
+    String comment = "    Four space indent.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseOneSpacePre() {
+    String comment = " One space indent.\n Another line.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseTabPre() {
+    String comment = "\tOne tab indent.\n\tAnother line.\n  Yet another!";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseIntermediateLeadingWhitespacePre() {
+    String comment = "No indent.\n\tNonzero indent.\nNo indent again.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertBlock(result, 0, PRE_FORMATTED, comment);
+  }
+
+  @Test
+  public void testParseStarList() {
+    String comment = "* Item 1\n* Item 2\n* Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void testParseDashList() {
+    String comment = "- Item 1\n- Item 2\n- Item 3";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+  }
+
+  @Test
+  public void testParseMixedList() {
+    String comment = "- Item 1\n* Item 2\n- Item 3\n* Item 4";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+    assertListBlock(result, 0, 0, "Item 1");
+    assertListBlock(result, 0, 1, "Item 2");
+    assertListBlock(result, 0, 2, "Item 3");
+    assertListBlock(result, 0, 3, "Item 4");
+  }
+
+  @Test
+  public void testParseMixedBlockTypes() {
+    String comment = "Paragraph\nacross\na\nfew\nlines."
+        + "\n\n"
+        + "> Quote\n> across\n> not many lines."
+        + "\n\n"
+        + "Another paragraph"
+        + "\n\n"
+        + "* Series\n* of\n* list\n* items"
+        + "\n\n"
+        + "Yet another paragraph"
+        + "\n\n"
+        + "\tPreformatted text."
+        + "\n\n"
+        + "Parting words.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(7);
+    assertBlock(result, 0, PARAGRAPH, "Paragraph\nacross\na\nfew\nlines.");
+    assertBlock(result, 1, QUOTE, "Quote\nacross\nnot many lines.");
+    assertBlock(result, 2, PARAGRAPH, "Another paragraph");
+    assertListBlock(result, 3, 0, "Series");
+    assertListBlock(result, 3, 1, "of");
+    assertListBlock(result, 3, 2, "list");
+    assertListBlock(result, 3, 3, "items");
+    assertBlock(result, 4, PARAGRAPH, "Yet another paragraph");
+    assertBlock(result, 5, PRE_FORMATTED, "\tPreformatted text.");
+    assertBlock(result, 6, PARAGRAPH, "Parting words.");
+  }
+
+  @Test
+  public void testBulletList1() {
+    String comment = "A\n\n* line 1\n* 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void testBulletList2() {
+    String comment = "A\n\n* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testBulletList3() {
+    String comment = "* line 1\n* 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testBulletList4() {
+    String comment = "To see this bug, you have to:\n" //
+        + "* Be on IMAP or EAS (not on POP)\n"//
+        + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void testBulletList5() {
+    String comment = "To see this bug,\n" //
+        + "you have to:\n" //
+        + "* Be on IMAP or EAS (not on POP)\n"//
+        + "* Be very unlucky\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "To see this bug, you have to:");
+    assertListBlock(result, 1, 0, "Be on IMAP or EAS (not on POP)");
+    assertListBlock(result, 1, 1, "Be very unlucky");
+  }
+
+  @Test
+  public void testDashList1() {
+    String comment = "A\n\n- line 1\n- 2nd line";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+  }
+
+  @Test
+  public void testDashList2() {
+    String comment = "A\n\n- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertListBlock(result, 1, 0, "line 1");
+    assertListBlock(result, 1, 1, "2nd line");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testDashList3() {
+    String comment = "- line 1\n- 2nd line\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertListBlock(result, 0, 0, "line 1");
+    assertListBlock(result, 0, 1, "2nd line");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testPreformat1() {
+    String comment = "A\n\n  This is pre\n  formatted";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+  }
+
+  @Test
+  public void testPreformat2() {
+    String comment = "A\n\n  This is pre\n  formatted\n\nbut this is not";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  This is pre\n  formatted");
+    assertBlock(result, 2, PARAGRAPH, "but this is not");
+  }
+
+  @Test
+  public void testPreformat3() {
+    String comment = "A\n\n  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "A");
+    assertBlock(result, 1, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 2, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testPreformat4() {
+    String comment = "  Q\n    <R>\n  S\n\nB";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, PRE_FORMATTED, "  Q\n    <R>\n  S");
+    assertBlock(result, 1, PARAGRAPH, "B");
+  }
+
+  @Test
+  public void testQuote1() {
+    String comment = "> I'm happy\n > with quotes!\n\nSee above.";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(2);
+    assertBlock(result, 0, QUOTE, "I'm happy\nwith quotes!");
+    assertBlock(result, 1, PARAGRAPH, "See above.");
+  }
+
+  @Test
+  public void testQuote2() {
+    String comment = "See this said:\n\n > a quoted\n > string block\n\nOK?";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(3);
+    assertBlock(result, 0, PARAGRAPH, "See this said:");
+    assertBlock(result, 1, QUOTE, "a quoted\nstring block");
+    assertBlock(result, 2, PARAGRAPH, "OK?");
+  }
+
+  @Test
+  public void testNestedQuotes1() {
+    String comment = " > > prior\n > \n > next\n";
+    List<CommentFormatter.Block> result = CommentFormatter.parse(comment);
+
+    assertThat(result).hasSize(1);
+
+    // Note: block does not encode nesting.
+    assertBlock(result, 0, QUOTE, "> prior\n\nnext");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index a1a5fbc..5899e11 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -62,6 +62,7 @@
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.index.change.ChangeField;
@@ -87,6 +88,7 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
@@ -119,6 +121,7 @@
   @Inject protected ChangeQueryBuilder queryBuilder;
   @Inject protected GerritApi gApi;
   @Inject protected IdentifiedUser.GenericFactory userFactory;
+  @Inject protected ChangeEditModifier changeEditModifier;
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
   @Inject protected InMemoryDatabase schemaFactory;
@@ -1496,6 +1499,35 @@
   }
 
   @Test
+  public void hasEdit() throws Exception {
+    Account.Id user1 = createAccount("user1");
+    Account.Id user2 = createAccount("user2");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    PatchSet ps1 = db.patchSets().get(change1.currentPatchSetId());
+    Change change2 = insert(repo, newChange(repo));
+    PatchSet ps2 = db.patchSets().get(change2.currentPatchSetId());
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit");
+    assertThat(changeEditModifier.createEdit(change1, ps1))
+        .isEqualTo(RefUpdate.Result.NEW);
+    assertThat(changeEditModifier.createEdit(change2, ps2))
+        .isEqualTo(RefUpdate.Result.NEW);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit");
+    assertThat(changeEditModifier.createEdit(change2, ps2))
+        .isEqualTo(RefUpdate.Result.NEW);
+
+    requestContext.setContext(newRequestContext(user1));
+    assertQuery("has:edit", change2, change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:edit", change2);
+  }
+
+  @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     int n = 10;
diff --git a/lib/gwt/BUILD b/lib/gwt/BUILD
index da6f8eb..116f86c 100644
--- a/lib/gwt/BUILD
+++ b/lib/gwt/BUILD
@@ -23,6 +23,14 @@
 )
 
 java_library(
+  name = 'dev-neverlink',
+  exports = ['@dev//jar'],
+  visibility = ['//visibility:public'],
+  neverlink = 1,
+  data = ['//lib:LICENSE-Apache2.0'],
+)
+
+java_library(
   name = 'javax-validation_src',
   exports = ['@javax_validation//src'],
   visibility = ['//visibility:public'],
diff --git a/plugins/BUILD b/plugins/BUILD
index 4af46ac..27690c8 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -1,17 +1,9 @@
 load('//tools/bzl:genrule2.bzl', 'genrule2')
-
-CORE = [
-  'commit-message-length-validator',
-  'download-commands',
-  'hooks',
-  'replication',
-  'reviewnotes',
-  'singleusergroup'
-]
+load('//tools/bzl:plugins.bzl', 'CORE_PLUGINS')
 
 genrule2(
   name = 'core',
-  srcs = ['//plugins/%s:%s.jar' % (n, n) for n in CORE],
+  srcs = ['//plugins/%s:%s.jar' % (n, n) for n in CORE_PLUGINS],
   cmd = 'mkdir -p $$TMP/WEB-INF/plugins;' +
     'for s in $(SRCS) ; do ' +
     'ln -s $$ROOT/$$s $$TMP/WEB-INF/plugins;done;' +
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index eea302a..747336d 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit eea302a7dd069bc6fd84e09c10ddecebdb6301e1
+Subproject commit 747336da4bcca8000badf6e4ed05ada536645b16
diff --git a/plugins/replication b/plugins/replication
index 531ed17..bb1ee89 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 531ed177aaab613c5830b7578df2dd4d84a7f319
+Subproject commit bb1ee89bdf6bdd2dfdf5d16a11a18b077c7c523d
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
index 3066589..3b17756 100644
--- 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
@@ -91,6 +91,7 @@
     _handleRemove: function(e) {
       var toRemove = e.detail.account;
       this._removeAccount(toRemove);
+      this.$.entry.focus();
     },
 
     _removeAccount: function(toRemove) {
@@ -105,7 +106,6 @@
         }
         if (matches) {
           this.splice('accounts', i, 1);
-          this.$.entry.focus();
           return;
         }
       }
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 fdc3b23..a1b7852 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
@@ -177,7 +177,7 @@
         }
         gr-reply-dialog {
           min-width: initial;
-          width: 90vw;
+          width: 100vw;
         }
         .downloadContainer {
           display: none;
@@ -213,6 +213,9 @@
           flex: initial;
           margin-right: 0;
         }
+        .scrollable {
+          @apply(--layout-scroll);
+        }
       }
     </style>
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
@@ -348,6 +351,7 @@
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
     <gr-overlay id="replyOverlay"
+        class="scrollable"
         no-cancel-on-outside-click
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 4e68559..3142733 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -21,6 +21,7 @@
 <link rel="import" href="../../diff/gr-diff/gr-diff.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
@@ -64,7 +65,7 @@
       .row:not(.header):hover {
         background-color: #f5fafd;
       }
-      .row[selected] {
+      .row.selected {
         background-color: #ebf5fb;
       }
       .path {
@@ -145,7 +146,7 @@
         max-width: 25em;
       }
       @media screen and (max-width: 50em) {
-        .row[selected] {
+        .row.selected {
           background-color: transparent;
         }
         .stats {
@@ -207,7 +208,7 @@
         items="[[_shownFiles]]"
         as="file"
         initial-count="[[_fileListIncrement]]">
-      <div class="row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
+      <div class="file-row row" selected$="[[_computeFileSelected(index, selectedIndex)]]">
         <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
           <input type="checkbox" checked$="[[_computeReviewed(file, _reviewed)]]"
               data-path$="[[file.__path]]" on-change="_handleReviewedChange"
@@ -233,8 +234,17 @@
           [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
         </div>
         <div class$="[[_computeClass('stats', file.__path)]]">
-          <span class="added">+[[file.lines_inserted]]</span>
-          <span class="removed">-[[file.lines_deleted]]</span>
+          <span class="added" hidden$=[[file.binary]]>
+            +[[file.lines_inserted]]
+          </span>
+          <span class="removed" hidden$=[[file.binary]]>
+            -[[file.lines_deleted]]
+          </span>
+          <span class$="[[_computeBinaryClass(file.size_delta)]]"
+              hidden$=[[!file.binary]]>
+            [[_formatBytes(file.size_delta)]]
+            [[_formatPercentage(file.size, file.size_delta)]]
+          </span>
         </div>
         <div class="show-hide">
           <label class="show-hide">
@@ -263,6 +273,20 @@
         <span class="removed">-[[_patchChange.deleted]]</span>
       </div>
     </div>
+    <div class="row totalChanges">
+      <div class="total-stats" hidden$="[[_hideBinaryChangeTotals]]">
+        <span class="added">
+          [[_formatBytes(_patchChange.size_delta_inserted)]]
+          [[_formatPercentage(_patchChange.total_size,
+              _patchChange.size_delta_inserted)]]
+        </span>
+        <span class="removed">
+          [[_formatBytes(_patchChange.size_delta_deleted)]]
+          [[_formatPercentage(_patchChange.total_size,
+              _patchChange.size_delta_deleted)]]
+        </span>
+      </div>
+    </div>
     <gr-button
         class="fileListButton"
         id="incrementButton"
@@ -279,7 +303,11 @@
     </gr-button>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
-    <gr-diff-cursor id="cursor"></gr-diff-cursor>
+    <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
+    <gr-cursor-manager
+        id="fileCursor"
+        scroll-behavior="keep-visible"
+        cursor-target-class="selected"></gr-cursor-manager>
   </template>
   <script src="gr-file-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index ab889ac..b423f50 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -35,10 +35,6 @@
       drafts: Object,
       revisions: Object,
       projectConfig: Object,
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
       keyEventTarget: {
         type: Object,
         value: function() { return document.body; },
@@ -83,6 +79,10 @@
         type: Boolean,
         computed: '_shouldHideChangeTotals(_patchChange)',
       },
+      _hideBinaryChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+      },
       _shownFiles: {
         type: Array,
         computed: '_computeFilesShown(_numFilesShown, _files.*)',
@@ -174,12 +174,21 @@
       return filesNoCommitMsg.reduce(function(acc, obj) {
         var inserted = obj.lines_inserted ? obj.lines_inserted : 0;
         var deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+        var total_size = (obj.size && obj.binary) ? obj.size : 0;
+        var size_delta_inserted =
+            obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+        var size_delta_deleted =
+            obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
 
         return {
           inserted: acc.inserted + inserted,
           deleted: acc.deleted + deleted,
+          size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+          size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+          total_size: acc.total_size + total_size,
         };
-      }, {inserted: 0, deleted: 0});
+      }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
+        size_delta_deleted: 0, total_size: 0});
     },
 
     _getDiffPreferences: function() {
@@ -240,7 +249,7 @@
         this.set(['_shownFiles', i, '__expanded'], false);
         this.set(['_files', i, '__expanded'], false);
       }
-      this.$.cursor.handleDiffUpdate();
+      this.$.diffCursor.handleDiffUpdate();
     },
 
     _computeCommentsString: function(comments, patchNum, path) {
@@ -313,7 +322,7 @@
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      this.$.cursor.moveLeft();
+      this.$.diffCursor.moveLeft();
     },
 
     _handleShiftRightKey: function(e) {
@@ -321,20 +330,20 @@
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      this.$.cursor.moveRight();
+      this.$.diffCursor.moveRight();
     },
 
     _handleIKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      if (this.selectedIndex === undefined) { return; }
+      if (this.$.fileCursor.index === -1) { return; }
 
       e.preventDefault();
-      var expanded = this._files[this.selectedIndex].__expanded;
+      var expanded = this._files[this.$.fileCursor.index].__expanded;
       // Until Polymer 2.0, manual management of reflection between _files
       // and _shownFiles is necessary.
-      this.set(['_shownFiles', this.selectedIndex, '__expanded'],
+      this.set(['_shownFiles', this.$.fileCursor.index, '__expanded'],
           !expanded);
-      this.set(['_files', this.selectedIndex, '__expanded'], !expanded);
+      this.set(['_files', this.$.fileCursor.index, '__expanded'], !expanded);
     },
 
     _handleCapitalIKey: function(e) {
@@ -346,14 +355,11 @@
 
     _handleDownKey: function(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
       e.preventDefault();
       if (this._showInlineDiffs) {
-        this.$.cursor.moveDown();
+        this.$.diffCursor.moveDown();
       } else {
-        this.selectedIndex =
-            Math.min(this._numFilesShown, this.selectedIndex + 1);
-        this._scrollToSelectedFile();
+        this.$.fileCursor.next();
       }
     },
 
@@ -362,10 +368,9 @@
 
       e.preventDefault();
       if (this._showInlineDiffs) {
-        this.$.cursor.moveUp();
+        this.$.diffCursor.moveUp();
       } else {
-        this.selectedIndex = Math.max(0, this.selectedIndex - 1);
-        this._scrollToSelectedFile();
+        this.$.fileCursor.previous();
       }
     },
 
@@ -412,9 +417,9 @@
 
       e.preventDefault();
       if (e.shiftKey) {
-        this.$.cursor.moveToNextCommentThread();
+        this.$.diffCursor.moveToNextCommentThread();
       } else {
-        this.$.cursor.moveToNextChunk();
+        this.$.diffCursor.moveToNextChunk();
       }
     },
 
@@ -424,9 +429,9 @@
 
       e.preventDefault();
       if (e.shiftKey) {
-        this.$.cursor.moveToPreviousCommentThread();
+        this.$.diffCursor.moveToPreviousCommentThread();
       } else {
-        this.$.cursor.moveToPreviousChunk();
+        this.$.diffCursor.moveToPreviousChunk();
       }
     },
 
@@ -448,45 +453,34 @@
     },
 
     _openCursorFile: function() {
-      var diff = this.$.cursor.getTargetDiffElement();
+      var diff = this.$.diffCursor.getTargetDiffElement();
       page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
           diff.path));
     },
 
     _openSelectedFile: function(opt_index) {
       if (opt_index != null) {
-        this.selectedIndex = opt_index;
+        this.$.fileCursor.setCursorAtIndex(opt_index);
       }
       page.show(this._computeDiffURL(this.changeNum, this.patchRange,
-          this._files[this.selectedIndex].__path));
+          this._files[this.$.fileCursor.index].__path));
     },
 
     _addDraftAtTarget: function() {
-      var diff = this.$.cursor.getTargetDiffElement();
-      var target = this.$.cursor.getTargetLineElement();
+      var diff = this.$.diffCursor.getTargetDiffElement();
+      var target = this.$.diffCursor.getTargetLineElement();
       if (diff && target) {
         diff.addDraftAtLine(target);
       }
     },
 
-    _scrollToSelectedFile: function() {
-      var el = this.$$('.row[selected]');
-      var top = 0;
-      for (var node = el; node; node = node.offsetParent) {
-        top += node.offsetTop;
-      }
-
-      // Don't scroll if it's already in view.
-      if (top > window.pageYOffset &&
-          top < window.pageYOffset + window.innerHeight - el.clientHeight) {
-        return;
-      }
-
-      window.scrollTo(0, top - document.body.clientHeight / 2);
+    _shouldHideChangeTotals: function(_patchChange) {
+      return _patchChange.inserted === 0 && _patchChange.deleted === 0;
     },
 
-    _shouldHideChangeTotals: function(_patchChange) {
-      return (_patchChange.inserted === 0 && _patchChange.deleted === 0);
+    _shouldHideBinaryChangeTotals: function(_patchChange) {
+      return _patchChange.size_delta_inserted === 0 &&
+          _patchChange.size_delta_deleted === 0;
     },
 
     _computeFileSelected: function(index, selectedIndex) {
@@ -512,6 +506,31 @@
       return path === COMMIT_MESSAGE_PATH ? 'Commit message' : path;
     },
 
+    _formatBytes: function(bytes) {
+      if (bytes == 0) return '+/-0 B';
+      var bits = 1024;
+      var decimals = 1;
+      var sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+      var exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+      var prepend = bytes > 0 ? '+' : '';
+      return prepend + parseFloat((bytes / Math.pow(bits, exponent))
+          .toFixed(decimals)) + ' ' + sizes[exponent];
+    },
+
+    _formatPercentage: function(size, delta) {
+      var oldSize = size - delta;
+
+      if (oldSize === 0) { return ''; }
+
+      var percentage = Math.round(Math.abs(delta * 100 / oldSize));
+      return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
+    },
+
+    _computeBinaryClass: function(delta) {
+      if (delta === 0) { return; }
+      return delta >= 0 ? 'added' : 'removed';
+    },
+
     _computeClass: function(baseClass, path) {
       var classes = [baseClass];
       if (path === COMMIT_MESSAGE_PATH) {
@@ -533,8 +552,12 @@
         var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
 
         // Overwrite the cursor's list of diffs:
-        this.$.cursor.splice.apply(this.$.cursor,
-            ['diffs', 0, this.$.cursor.diffs.length].concat(diffElements));
+        this.$.diffCursor.splice.apply(this.$.diffCursor,
+            ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
+
+        var files = Polymer.dom(this.root).querySelectorAll('.file-row');
+        this.$.fileCursor.stops = files;
+        if (this.$.fileCursor.index === -1) { this.$.fileCursor.moveToStart(); }
       }.bind(this), 1);
     },
 
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 1e146b5..bc7c0c7 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -103,10 +103,30 @@
     test('calculate totals for patch number', function() {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
-        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
-        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
       ];
-      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
       // Test with a commit message that isn't the first file.
       element._files = [
@@ -114,21 +134,137 @@
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
       ];
-      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
       // Test with no commit message.
       element._files = [
         {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
         {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
       ];
-      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+      assert.deepEqual(element._patchChange, {
+        inserted: 2,
+        deleted: 2,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
 
       // Test with files missing either lines_inserted or lines_deleted.
       element._files = [
         {__path: 'file_added_in_rev2.txt', lines_inserted: 1},
         {__path: 'myfile.txt', lines_deleted: 1},
       ];
-      assert.deepEqual(element._patchChange, {inserted: 1, deleted: 1});
+      assert.deepEqual(element._patchChange, {
+        inserted: 1,
+        deleted: 1,
+        size_delta_inserted: 0,
+        size_delta_deleted: 0,
+        total_size: 0,
+      });
+      assert.isTrue(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('binary only files', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
+        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 0,
+        deleted: 0,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isTrue(element._hideChangeTotals);
+    });
+
+    test('binary and regular files', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
+        {__path: 'file_binary', binary: true, size_delta: -5, size: 120},
+        {__path: 'myfile.txt', lines_deleted: 5, size_delta: -10, size: 100},
+        {__path: 'myfile2.txt', lines_inserted: 10},
+      ];
+      assert.deepEqual(element._patchChange, {
+        inserted: 10,
+        deleted: 5,
+        size_delta_inserted: 10,
+        size_delta_deleted: -5,
+        total_size: 220,
+      });
+      assert.isFalse(element._hideBinaryChangeTotals);
+      assert.isFalse(element._hideChangeTotals);
+    });
+
+    test('_formatBytes function', function() {
+      var table = {
+        64: '+64 B',
+        1023: '+1023 B',
+        1024: '+1 KiB',
+        4096: '+4 KiB',
+        1073741824: '+1 GiB',
+        '-64': '-64 B',
+        '-1023': '-1023 B',
+        '-1024': '-1 KiB',
+        '-4096': '-4 KiB',
+        '-1073741824': '-1 GiB',
+        0: '+/-0 B',
+      };
+
+      for (var bytes in table) {
+        if (table.hasOwnProperty(bytes)) {
+          assert.equal(element._formatBytes(bytes), table[bytes]);
+        }
+      }
+    });
+
+    test('_formatPercentage function', function() {
+      var table = [
+        { size: 100,
+          delta: 100,
+          display: '',
+        },
+        { size: 195060,
+          delta: 64,
+          display: '(+0%)',
+        },
+        { size: 195060,
+          delta: -64,
+          display: '(-0%)',
+        },
+        { size: 394892,
+          delta: -7128,
+          display: '(-2%)',
+        },
+        { size: 90,
+          delta: -10,
+          display: '(-10%)',
+        },
+        { size: 110,
+          delta: 10,
+          display: '(+10%)',
+        },
+      ];
+
+      table.forEach(function(item) {
+        assert.equal(element._formatPercentage(
+            item.size, item.delta), item.display);
+      });
     });
 
     suite('keyboard shortcuts', function() {
@@ -143,7 +279,7 @@
           basePatchNum: 'PARENT',
           patchNum: '2',
         };
-        element.selectedIndex = 0;
+        element.$.fileCursor.setCursorAtIndex(0);
       });
 
       test('toggle left diff via shortcut', function() {
@@ -162,24 +298,26 @@
 
       test('keyboard shortcuts', function() {
         flushAsynchronousOperations();
-        var elementItems = Polymer.dom(element.root).querySelectorAll(
-            '.row:not(.header)');
-        assert.equal(elementItems.length, 4);
-        assert.isTrue(elementItems[0].hasAttribute('selected'));
-        assert.isFalse(elementItems[1].hasAttribute('selected'));
-        assert.isFalse(elementItems[2].hasAttribute('selected'));
+
+        var items = Polymer.dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = items;
+        element.$.fileCursor.setCursorAtIndex(0);
+        assert.equal(items.length, 3);
+        assert.isTrue(items[0].classList.contains('selected'));
+        assert.isFalse(items[1].classList.contains('selected'));
+        assert.isFalse(items[2].classList.contains('selected'));
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-        assert.equal(element.selectedIndex, 1);
+        assert.equal(element.$.fileCursor.index, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
         var showStub = sandbox.stub(page, 'show');
-        assert.equal(element.selectedIndex, 2);
+        assert.equal(element.$.fileCursor.index, 2);
         MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
         assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
             'Should navigate to /c/42/2/myfile.txt');
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.selectedIndex, 1);
+        assert.equal(element.$.fileCursor.index, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
         assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
             'Should navigate to /c/42/2/file_added_in_rev2.txt');
@@ -187,20 +325,20 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.selectedIndex, 0);
-
-        showStub.restore();
+        assert.equal(element.$.fileCursor.index, 0);
       });
 
       test('i key shows/hides selected inline diff', function() {
-        element.selectedIndex = 0;
+        flushAsynchronousOperations();
+        element.$.fileCursor.stops = element.diffs;
+        element.$.fileCursor.setCursorAtIndex(0);
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.isFalse(element.diffs[0].hasAttribute('hidden'));
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.isTrue(element.diffs[0].hasAttribute('hidden'));
-        element.selectedIndex = 1;
+        element.$.fileCursor.setCursorAtIndex(1);
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
         assert.isFalse(element.diffs[1].hasAttribute('hidden'));
@@ -280,7 +418,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
 
       flushAsynchronousOperations();
       var fileRows =
@@ -363,7 +501,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
       flushAsynchronousOperations();
       var fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
@@ -401,7 +539,7 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
       flushAsynchronousOperations();
       var diffDisplay = element.diffs[0];
       element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
@@ -444,7 +582,7 @@
         patchNum: '2',
       };
       var computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-      element.selectedIndex = 0;
+      element.$.fileCursor.setCursorAtIndex(0);
       element._numFilesShown = 1;
       flush(function() {
         assert.isTrue(computeSpy.lastCall.returnValue);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 1afd548..3435547 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -76,6 +76,10 @@
       .message {
         max-width: 80ch;
       }
+      .collapsed .message {
+        overflow: hidden;
+        text-overflow: ellipsis;
+      }
       .collapsed .name,
       .collapsed .content,
       .collapsed .message,
@@ -93,6 +97,7 @@
       }
       .collapsed .content {
         flex: 1;
+        margin-right: .25em;
         min-width: 0;
         overflow: hidden;
         text-overflow: ellipsis;
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index d04a942..7c1759d 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -48,7 +48,7 @@
             on-tap="_handleExpandCollapseTap">
           [[_computeExpandCollapseMessage(_expanded)]]
         </gr-button>
-        <div
+        <span
             id="automatedMessageToggleContainer"
             hidden$="[[!_hasAutomatedMessages(messages)]]">
           /
@@ -56,7 +56,7 @@
               on-tap="_handleAutomatedMessageToggleTap">
             [[_computeAutomatedToggleText(_hideAutomated)]]
           </gr-button>
-        </div>
+        </span>
       </div>
     </div>
     <template
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 39db8e4..9b7a11f0 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
@@ -138,6 +138,14 @@
       .action:visited {
         color: #00e;
       }
+      @media screen and (max-width: 50em) {
+        :host {
+          max-height: none;
+        }
+        .container {
+          max-height: none;
+        }
+      }
     </style>
     <div class="container">
       <section class="peopleContainer">
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 051e7df..497cae4 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
@@ -384,9 +384,6 @@
     var text = line.text;
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
-      if (!text) {
-        text = '\xa0';
-      }
     }
     td.classList.add(line.type);
     var html = util.escapeHTML(text);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
index c8eea3c..2d0786a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -21,7 +21,7 @@
   <template>
     <gr-cursor-manager
         id="cursorManager"
-        scroll="[[_scrollBehavior]]"
+        scroll-behavior="[[_scrollBehavior]]"
         cursor-target-class="target-row"
         target="{{diffRow}}"></gr-cursor-manager>
   </template>
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 9bbcea5..1d2beab 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
@@ -42,7 +42,7 @@
       <span class="text">
         <span>[[account.name]]</span>
         <span hidden$="[[!_computeShowEmail(showEmail, account)]]">
-          ([[account.email]])
+          [[_computeEmailStr(account)]]
         </span>
       </span>
     </span>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index 40b7cf1..ffbb55b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -44,5 +44,12 @@
     _computeShowEmail: function(showEmail, account) {
       return !!(showEmail && account && account.email);
     },
+
+    _computeEmailStr: function(account) {
+      if (account.name) {
+        return '(' + account.email + ')';
+      }
+      return account.email;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index f3d8861..65331d6 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -71,6 +71,9 @@
 
       assert.equal(element._computeShowEmail(
           false, undefined), false);
+
+      assert.equal(element._computeEmailStr({name: 'test', email: 'test'}), '(test)');
+      assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
     });
 
   });
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 5de54bb..d7017ca 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -73,6 +73,7 @@
         id="cursor"
         index="{{_index}}"
         cursor-target-class="selected"
+        scroll-behavior="keep-visible"
         stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
   </template>
   <script src="gr-autocomplete.js"></script>
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 0626288..863b85a 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -199,6 +199,7 @@
     },
 
     _handleInputKeydown: function(e) {
+      this._focused = true;
       switch (e.keyCode) {
         case 38: // Up
           e.preventDefault();
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 7b3bc23..81f5186 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -59,7 +59,7 @@
        * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
        * the viewport.
        */
-      scroll: {
+      scrollBehavior: {
         type: String,
         value: ScrollBehavior.NEVER,
       },
@@ -108,6 +108,10 @@
       }
     },
 
+    setCursorAtIndex: function(index) {
+      this.setCursor(this.stops[index]);
+    },
+
     /**
      * Move the cursor forward or backward by delta. Noop if moving past either
      * end of the stop list.
@@ -194,7 +198,9 @@
     },
 
     _scrollToTarget: function() {
-      if (!this.target || this.scroll === ScrollBehavior.NEVER) { return; }
+      if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
+        return;
+      }
 
       // Calculate where the element is relative to the window.
       var top = this.target.offsetTop;
@@ -204,7 +210,7 @@
         top += offsetParent.offsetTop;
       }
 
-      if (this.scroll === ScrollBehavior.KEEP_VISIBLE &&
+      if (this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
           top > window.pageYOffset &&
           top < window.pageYOffset + window.innerHeight) { return; }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 07ccf2b..a98b00b 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -212,6 +212,8 @@
     },
 
     saveDiffPreferences: function(prefs, opt_errFn, opt_ctx) {
+      // Invalidate the cache.
+      this._cache['/accounts/self/preferences.diff'] = undefined;
       return this.send('PUT', '/accounts/self/preferences.diff', prefs,
           opt_errFn, opt_ctx);
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 392c320..d07968f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -330,5 +330,14 @@
       element.getChanges(1, null, 'n,z');
       assert.equal(stub.args[0][3].S, 0);
     });
+
+    test('saveDiffPreferences invalidates cache line', function() {
+      var cacheKey = '/accounts/self/preferences.diff';
+      var sendStub = sandbox.stub(element, 'send');
+      element._cache[cacheKey] = {tab_size: 4};
+      element.saveDiffPreferences({tab_size: 8});
+      assert.isTrue(sendStub.called);
+      assert.notOk(element._cache[cacheKey]);
+    });
   });
 </script>
diff --git a/tools/bzl/plugins.bzl b/tools/bzl/plugins.bzl
new file mode 100644
index 0000000..287a989
--- /dev/null
+++ b/tools/bzl/plugins.bzl
@@ -0,0 +1,12 @@
+CORE_PLUGINS = [
+  'commit-message-length-validator',
+  'download-commands',
+  'hooks',
+  'replication',
+  'reviewnotes',
+  'singleusergroup',
+]
+
+CUSTOM_PLUGINS = [
+  'cookbook-plugin',
+]
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index ac55c72..41c89b1 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -1,5 +1,8 @@
 load('//tools/bzl:pkg_war.bzl', 'LIBS', 'PGMLIBS')
 load('//tools/bzl:classpath.bzl', 'classpath_collector')
+load('//tools/bzl:plugins.bzl',
+     'CORE_PLUGINS',
+     'CUSTOM_PLUGINS')
 
 PROVIDED_DEPS = [
   '//lib/bouncycastle:bcprov',
@@ -23,8 +26,6 @@
   '//gerrit-main:main_lib',
   '//gerrit-plugin-gwtui:gwtui-api-lib',
   '//gerrit-server:server',
-  # TODO(davido): figure out why it's not reached through test dependencies
-  '//lib:jimfs',
   '//lib/asciidoctor:asciidoc_lib',
   '//lib/asciidoctor:doc_indexer_lib',
   '//lib/auto:auto-value',
@@ -50,10 +51,10 @@
 
 classpath_collector(
   name = 'main_classpath_collect',
-  deps = LIBS + PGMLIBS + DEPS + PROVIDED_DEPS,
+  deps = LIBS + PGMLIBS + DEPS + TEST_DEPS + PROVIDED_DEPS +
+    ['//plugins/%s:%s__plugin' % (n, n)
+     for n in CORE_PLUGINS + CUSTOM_PLUGINS],
   testonly = 1,
-  # TODO(davido): Handle plugins
-  #scan_plugins(),
 )
 
 classpath_collector(
diff --git a/tools/eclipse/project_bzl.py b/tools/eclipse/project_bzl.py
index 1d07f8a..86fb527 100755
--- a/tools/eclipse/project_bzl.py
+++ b/tools/eclipse/project_bzl.py
@@ -158,6 +158,10 @@
          p.endswith('prolog/libcommon.jar'):
         lib.add(p)
     else:
+      # Don't mess up with Bazel internal test runner dependencies.
+      # When we use Eclipse we rely on it for running the tests
+      if p.endswith("external/bazel_tools/tools/jdk/TestRunner_deploy.jar"):
+        continue
       if p.startswith("external"):
         p = path.join(ext, p)
       lib.add(p)
@@ -169,9 +173,8 @@
       # Exception: we need source here for GWT SDM mode to work
       if p.endswith('libEdit.jar'):
         p = p[:-4] + '-src.jar'
-        assert path.exists(p), p
         lib.add(p)
-      
+
   for s in sorted(src):
     out = None
 
@@ -258,14 +261,14 @@
   gen_classpath(ext_location)
   gen_factorypath(ext_location)
   gen_primary_build_tool()
-  
+
   # TODO(davido): Remove this when GWT gone
   gwt_working_dir = ".gwt_work_dir"
   if not path.isdir(gwt_working_dir):
     makedirs(path.join(ROOT, gwt_working_dir))
 
   try:
-    check_call(['bazel', 'build', MAIN, GWT])
+    check_call(['bazel', 'build', MAIN, GWT, '//gerrit-patch-jgit:libEdit-src.jar'])
   except CalledProcessError:
     exit(1)
 except KeyboardInterrupt: