Merge "Remove required access permission to get account username"
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
index 4c75ad4..4746fee 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -218,6 +218,10 @@
     this.force = force;
   }
 
+  public void noParents() {
+    commitBuilder.noParents();
+  }
+
   public class Result {
     private final String ref;
     private final PushResult result;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index 72fcd03..c390c67 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
@@ -84,4 +85,37 @@
         "and upload the rebased commit for review.");
     assertThat(getRemoteHead()).isEqualTo(oldHead);
   }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    PushOneCommit.Result change2 = createChange();
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+    assertThat(getRemoteHead().getId()).isEqualTo(change2.getCommitId());
+  }
+
+  @Test
+  @TestProjectInput(createEmptyCommit = false)
+  public void submitMultipleCommitsToEmptyRepoWithOneMerge() throws Exception {
+    assume().that(isSubmitWholeTopicEnabled()).isTrue();
+    PushOneCommit.Result change1 = pushFactory.create(
+          db, admin.getIdent(), testRepo, "Change 1", "a", "a")
+        .to("refs/for/master/" + name("topic"));
+
+    PushOneCommit push2 = pushFactory.create(
+          db, admin.getIdent(), testRepo, "Change 2", "b", "b");
+    push2.noParents();
+    PushOneCommit.Result change2 = push2.to("refs/for/master/" + name("topic"));
+    change2.assertOkStatus();
+
+    approve(change1.getChangeId());
+    submit(change2.getChangeId());
+
+    RevCommit head = getRemoteHead();
+    assertThat(head.getParents()).hasLength(2);
+    assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
+    assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
+  }
 }
diff --git a/gerrit-gwtexpui/BUCK b/gerrit-gwtexpui/BUCK
index 4b2cb03..79a97a9 100644
--- a/gerrit-gwtexpui/BUCK
+++ b/gerrit-gwtexpui/BUCK
@@ -7,7 +7,7 @@
   resources = [
     SRC + 'clippy/client/clippy.css',
     SRC + 'clippy/client/clippy.swf',
-    SRC + 'clippy/client/clipboard-16.png',
+    SRC + 'clippy/client/page_white_copy.png',
     SRC + 'clippy/client/CopyableLabelText.properties',
   ],
   provided_deps = ['//lib/gwt:user'],
@@ -15,7 +15,7 @@
     ':SafeHtml',
     ':UserAgent',
     '//lib:LICENSE-clippy',
-    '//lib:LICENSE-drifty',
+    '//lib:LICENSE-silk_icons',
   ],
   visibility = ['PUBLIC'],
 )
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java
index dd3cc18..f3435cc 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/ClippyResources.java
@@ -30,6 +30,6 @@
   @DoNotEmbed
   DataResource swf();
 
-  @Source("clipboard-16.png")
+  @Source("page_white_copy.png")
   ImageResource clipboard();
 }
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clipboard-16.png b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clipboard-16.png
deleted file mode 100644
index 9c6e10a..0000000
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/clipboard-16.png
+++ /dev/null
Binary files differ
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png
new file mode 100644
index 0000000..a9f31a2
--- /dev/null
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/clippy/client/page_white_copy.png
Binary files differ
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
index 85b350d..fa4be77 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/ModeInfo.java
@@ -73,6 +73,7 @@
       Modes.I.soy(),
       Modes.I.sql(),
       Modes.I.stex(),
+      Modes.I.swift(),
       Modes.I.velocity(),
       Modes.I.verilog(),
       Modes.I.vhdl(),
diff --git a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
index 42990b8..4faaccf 100644
--- a/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
+++ b/gerrit-gwtui/src/main/java/net/codemirror/mode/Modes.java
@@ -55,6 +55,7 @@
   @Source("scheme.js") @DoNotEmbed DataResource scheme();
   @Source("shell.js") @DoNotEmbed DataResource shell();
   @Source("smalltalk.js") @DoNotEmbed DataResource smalltalk();
+  @Source("swift.js") @DoNotEmbed DataResource swift();
   @Source("soy.js") @DoNotEmbed DataResource soy();
   @Source("sql.js") @DoNotEmbed DataResource sql();
   @Source("stex.js") @DoNotEmbed DataResource stex();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
index 41b9736..c9d65f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftChangeOp.java
@@ -104,7 +104,7 @@
     prefix = prefix.substring(0, prefix.length() - 1);
     for (Ref ref
         : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
-      ctx.getBatchRefUpdate().addCommand(
+      ctx.addRefUpdate(
           new ReceiveCommand(
             ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
index 7edf483..72b6bf2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
@@ -122,7 +122,7 @@
         deleteChangeOp.updateRepo(ctx);
         return;
       }
-      ctx.getBatchRefUpdate().addCommand(
+      ctx.addRefUpdate(
           new ReceiveCommand(
               ObjectId.fromString(patchSet.getRevision().get()),
               ObjectId.zeroId(),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 8628316..cb2c2bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -44,15 +44,14 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ChangeModifiedException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.ssh.SshInfo;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -210,8 +209,8 @@
     ChangeControl ctl = ctx.getChangeControl();
 
     change = ctx.getChange();
-    Change.Id id = change.getId();
-    final PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+    ChangeUpdate update = ctx.getChangeUpdate();
+
     if (!change.getStatus().isOpen() && !allowClosed) {
       throw new InvalidChangeOperationException(String.format(
           "Change %s is closed", change.getId()));
@@ -223,6 +222,8 @@
     patchSet.setRevision(new RevId(commit.name()));
     patchSet.setDraft(draft);
 
+    update.setPatchSetId(patchSet.getId());
+
     if (groups != null) {
       patchSet.setGroups(groups);
     } else {
@@ -242,30 +243,12 @@
     }
 
     patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
-    // TODO(dborowitz): Throw ResourceConflictException instead of using
-    // AtomicUpdate.
-    change = db.changes().atomicUpdate(id, new AtomicUpdate<Change>() {
-      @Override
-      public Change update(Change change) {
-        if (change.getStatus().isClosed() && !allowClosed) {
-          return null;
-        }
-        if (!change.currentPatchSetId().equals(currentPatchSetId)) {
-          return null;
-        }
-        if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
-          change.setStatus(Change.Status.NEW);
-        }
-        change.setCurrentPatchSet(patchSetInfo);
-        ChangeUtil.updated(change);
-        return change;
-      }
-    });
-    if (change == null) {
-      throw new ChangeModifiedException(String.format(
-          "Change %s was modified", id));
+    if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
+      change.setStatus(Change.Status.NEW);
     }
-
+    change.setCurrentPatchSet(patchSetInfo);
+    ChangeUtil.updated(change);
+    db.changes().update(Collections.singleton(change));
     approvalCopier.copy(db, ctl, patchSet);
     if (changeMessage != null) {
       cmUtil.addChangeMessage(db, ctx.getChangeUpdate(), changeMessage);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
index 7a8cb9ae..991f6c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
@@ -146,7 +146,7 @@
       return BatchUpdate.this.getObjectInserter();
     }
 
-    public BatchRefUpdate getBatchRefUpdate() throws IOException {
+    private BatchRefUpdate getBatchRefUpdate() throws IOException {
       initRepository();
       if (batchRefUpdate == null) {
         batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
index 0da24c8..3f0e833 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
@@ -14,9 +14,16 @@
 
 package com.google.gerrit.server.git.strategy;
 
+import static com.google.gerrit.server.git.strategy.MarkCleanMergesOp.anyChangeId;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeTip;
+import com.google.gerrit.server.git.UpdateException;
 
 import java.util.Collection;
 import java.util.List;
@@ -27,24 +34,46 @@
   }
 
   @Override
-  public MergeTip run(final CodeReviewCommit branchTip,
-      final Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+  public MergeTip run(CodeReviewCommit branchTip,
+      Collection<CodeReviewCommit> toMerge) throws IntegrationException {
+    List<CodeReviewCommit> sorted =
+        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     MergeTip mergeTip = new MergeTip(branchTip, toMerge);
-    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(
-        args.mergeSorter, toMerge);
-    final CodeReviewCommit newMergeTipCommit =
-        args.mergeUtil.getFirstFastForward(branchTip, args.rw, sorted);
-    mergeTip.moveTipTo(newMergeTipCommit, newMergeTipCommit);
+    try (BatchUpdate u = args.newBatchUpdate(TimeUtil.nowTs())) {
+      CodeReviewCommit newTipCommit =
+          args.mergeUtil.getFirstFastForward(branchTip, args.rw, sorted);
+      if (!newTipCommit.equals(branchTip)) {
+        u.addOp(newTipCommit.change().getId(),
+            new FastForwardOp(mergeTip, newTipCommit));
+      }
+      while (!sorted.isEmpty()) {
+        CodeReviewCommit n = sorted.remove(0);
+        u.addOp(n.change().getId(), new NotFastForwardOp(n));
+      }
+      u.addOp(anyChangeId(toMerge), new MarkCleanMergesOp(args, mergeTip));
 
-    while (!sorted.isEmpty()) {
-      final CodeReviewCommit n = sorted.remove(0);
-      n.setStatusCode(CommitMergeStatus.NOT_FAST_FORWARD);
+      u.execute();
+    } catch (RestApiException | UpdateException e) {
+      if (e.getCause() instanceof IntegrationException) {
+        throw new IntegrationException(e.getCause().getMessage(), e);
+      }
+      throw new IntegrationException(
+          "Cannot fast-forward into " + args.destBranch);
+    }
+    return mergeTip;
+  }
+
+  private static class NotFastForwardOp extends BatchUpdate.Op {
+    private final CodeReviewCommit toMerge;
+
+    private NotFastForwardOp(CodeReviewCommit toMerge) {
+      this.toMerge = toMerge;
     }
 
-    args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-        newMergeTipCommit, args.alreadyAccepted);
-
-    return mergeTip;
+    @Override
+    public void updateRepo(RepoContext ctx) {
+      toMerge.setStatusCode(CommitMergeStatus.NOT_FAST_FORWARD);
+    }
   }
 
   static boolean dryRun(SubmitDryRun.Arguments args,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
new file mode 100644
index 0000000..19d5080
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOp.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeTip;
+
+import java.io.IOException;
+
+class FastForwardOp extends BatchUpdate.Op {
+  private final MergeTip mergeTip;
+  private final CodeReviewCommit toMerge;
+
+  FastForwardOp(MergeTip mergeTip,
+      CodeReviewCommit toMerge) {
+    this.mergeTip = mergeTip;
+    this.toMerge = toMerge;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx)
+      throws IntegrationException, IOException {
+    mergeTip.moveTipTo(toMerge, toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MarkCleanMergesOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MarkCleanMergesOp.java
new file mode 100644
index 0000000..9a7b9b5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MarkCleanMergesOp.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.strategy;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.Context;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeTip;
+
+class MarkCleanMergesOp extends BatchUpdate.Op {
+  static Change.Id anyChangeId(Iterable<CodeReviewCommit> commits) {
+    for (CodeReviewCommit c : commits) {
+      if (c.change() != null) {
+        return c.change().getId();
+      }
+    }
+    throw new IllegalArgumentException(
+        "no CodeReviewCommits have changes: " + commits);
+  }
+  private final SubmitStrategy.Arguments args;
+  private final MergeTip mergeTip;
+
+  MarkCleanMergesOp(SubmitStrategy.Arguments args,
+      MergeTip mergeTip) {
+    this.args = args;
+    this.mergeTip = mergeTip;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws IntegrationException {
+    // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
+    // When hoisting BatchUpdate into MergeOp, we will need to teach
+    // BatchUpdate how to produce CodeReviewRevWalks.
+    args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
+        mergeTip.getCurrentTip(), args.alreadyAccepted);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
index 94fc588..329aef0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.git.strategy;
 
+import static com.google.gerrit.server.git.strategy.MarkCleanMergesOp.anyChangeId;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeTip;
-
-import org.eclipse.jgit.lib.PersonIdent;
+import com.google.gerrit.server.git.UpdateException;
 
 import java.util.Collection;
 import java.util.List;
@@ -31,30 +35,30 @@
   @Override
   public MergeTip run(CodeReviewCommit branchTip,
       Collection<CodeReviewCommit> toMerge) throws IntegrationException {
-  List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    MergeTip mergeTip;
-    if (branchTip == null) {
-      // The branch is unborn. Take a fast-forward resolution to
-      // create the branch.
-      mergeTip = new MergeTip(sorted.get(0), toMerge);
-      sorted.remove(0);
-    } else {
-      mergeTip = new MergeTip(branchTip, toMerge);
-    }
-    while (!sorted.isEmpty()) {
-      CodeReviewCommit mergedFrom = sorted.remove(0);
-      PersonIdent caller = args.caller.newCommitterIdent(
-          args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
-      CodeReviewCommit newTip =
-          args.mergeUtil.mergeOneCommit(caller, args.serverIdent,
-              args.repo, args.rw, args.inserter, args.canMergeFlag,
-              args.destBranch, mergeTip.getCurrentTip(), mergedFrom);
-      mergeTip.moveTipTo(newTip, mergedFrom);
-    }
+    List<CodeReviewCommit> sorted =
+        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+    MergeTip mergeTip = new MergeTip(branchTip, toMerge);
+    try (BatchUpdate u = args.newBatchUpdate(TimeUtil.nowTs())) {
+      if (branchTip == null) {
+        // The branch is unborn. Take a fast-forward resolution to
+        // create the branch.
+        CodeReviewCommit first = sorted.remove(0);
+        u.addOp(first.change().getId(), new FastForwardOp(mergeTip, first));
+      }
+      while (!sorted.isEmpty()) {
+        CodeReviewCommit n = sorted.remove(0);
+        u.addOp(n.change().getId(), new MergeOneOp(args, mergeTip, n));
+      }
+      u.addOp(anyChangeId(toMerge), new MarkCleanMergesOp(args, mergeTip));
 
-    args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag,
-        mergeTip.getCurrentTip(), args.alreadyAccepted);
-
+      u.execute();
+    } catch (RestApiException | UpdateException e) {
+      if (e.getCause() instanceof IntegrationException) {
+        throw new IntegrationException(e.getCause().getMessage(), e);
+      }
+      throw new IntegrationException(
+          "Cannot merge into " + args.destBranch);
+    }
     return mergeTip;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
index ce9dd5b..40ab2c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.git.strategy;
 
+import static com.google.gerrit.server.git.strategy.MarkCleanMergesOp.anyChangeId;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeTip;
-
-import org.eclipse.jgit.lib.PersonIdent;
+import com.google.gerrit.server.git.UpdateException;
 
 import java.util.Collection;
 import java.util.List;
@@ -33,33 +37,37 @@
       Collection<CodeReviewCommit> toMerge) throws IntegrationException {
     List<CodeReviewCommit> sorted =
         args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
-    MergeTip mergeTip;
-    if (branchTip == null) {
-      // The branch is unborn. Take a fast-forward resolution to
-      // create the branch.
-      mergeTip = new MergeTip(sorted.get(0), toMerge);
-      branchTip = sorted.remove(0);
-    } else {
-      mergeTip = new MergeTip(branchTip, toMerge);
-      branchTip =
-          args.mergeUtil.getFirstFastForward(branchTip, args.rw, sorted);
-    }
-    mergeTip.moveTipTo(branchTip, branchTip);
+    MergeTip mergeTip = new MergeTip(branchTip, toMerge);
+    try (BatchUpdate u = args.newBatchUpdate(TimeUtil.nowTs())) {
+      // Start with the first fast-forward. This may create the branch if it did
+      // not exist.
+      CodeReviewCommit firstFastForward;
+      if (branchTip == null) {
+        firstFastForward = sorted.remove(0);
+      } else {
+        firstFastForward =
+            args.mergeUtil.getFirstFastForward(branchTip, args.rw, sorted);
+      }
+      if (!firstFastForward.equals(branchTip)) {
+        u.addOp(firstFastForward.change().getId(),
+            new FastForwardOp(mergeTip, firstFastForward));
+      }
 
-    // For every other commit do a pair-wise merge.
-    while (!sorted.isEmpty()) {
-      CodeReviewCommit mergedFrom = sorted.remove(0);
-      PersonIdent caller = args.caller.newCommitterIdent(
-          args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
-      branchTip =
-          args.mergeUtil.mergeOneCommit(caller, args.serverIdent,
-              args.repo, args.rw, args.inserter, args.canMergeFlag,
-              args.destBranch, branchTip, mergedFrom);
-      mergeTip.moveTipTo(branchTip, mergedFrom);
-    }
+      // For every other commit do a pair-wise merge.
+      while (!sorted.isEmpty()) {
+        CodeReviewCommit n = sorted.remove(0);
+        u.addOp(n.change().getId(), new MergeOneOp(args, mergeTip, n));
+      }
+      u.addOp(anyChangeId(toMerge), new MarkCleanMergesOp(args, mergeTip));
 
-    args.mergeUtil.markCleanMerges(args.rw, args.canMergeFlag, branchTip,
-        args.alreadyAccepted);
+      u.execute();
+    } catch (RestApiException | UpdateException e) {
+      if (e.getCause() instanceof IntegrationException) {
+        throw new IntegrationException(e.getCause().getMessage(), e);
+      }
+      throw new IntegrationException(
+          "Cannot merge into " + args.destBranch);
+    }
     return mergeTip;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
new file mode 100644
index 0000000..834a6c0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
@@ -0,0 +1,58 @@
+// 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.git.strategy;
+
+import com.google.gerrit.server.git.BatchUpdate;
+import com.google.gerrit.server.git.BatchUpdate.RepoContext;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.IntegrationException;
+import com.google.gerrit.server.git.MergeTip;
+
+import org.eclipse.jgit.lib.PersonIdent;
+
+import java.io.IOException;
+
+class MergeOneOp extends BatchUpdate.Op {
+  private final SubmitStrategy.Arguments args;
+  private final MergeTip mergeTip;
+  private final CodeReviewCommit toMerge;
+
+  MergeOneOp(SubmitStrategy.Arguments args, MergeTip mergeTip,
+      CodeReviewCommit toMerge) {
+    this.args = args;
+    this.mergeTip = mergeTip;
+    this.toMerge = toMerge;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx)
+      throws IntegrationException, IOException {
+    PersonIdent caller = ctx.getUser().asIdentifiedUser().newCommitterIdent(
+        ctx.getWhen(), ctx.getTimeZone());
+    if (mergeTip.getCurrentTip() == null) {
+      throw new IllegalStateException("cannot merge commit " + toMerge.name()
+          + " onto a null tip; expected at least one fast-forward prior to"
+          + " this operation");
+    }
+    // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
+    // When hoisting BatchUpdate into MergeOp, we will need to teach
+    // BatchUpdate how to produce CodeReviewRevWalks.
+    CodeReviewCommit merged =
+        args.mergeUtil.mergeOneCommit(caller, args.serverIdent,
+            ctx.getRepository(), args.rw, ctx.getInserter(), args.canMergeFlag,
+            args.destBranch, mergeTip.getCurrentTip(), toMerge);
+    mergeTip.moveTipTo(merged, toMerge);
+  }
+}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
index 912e4b0..e3fb157 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -46,6 +46,7 @@
 soy = text/x-soy
 st = text/x-stsrc
 stex = text/x-stex
+swift = text/x-swift
 v = text/x-verilog
 vert = x-shader/x-vertex
 vh = text/x-verilog
diff --git a/lib/BUCK b/lib/BUCK
index a0924aa..8a2d22b 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -10,7 +10,6 @@
 define_license(name = 'clippy')
 define_license(name = 'codemirror')
 define_license(name = 'diffy')
-define_license(name = 'drifty')
 define_license(name = 'freebie_application_icon_set')
 define_license(name = 'h2')
 define_license(name = 'jgit')
diff --git a/lib/LICENSE-drifty b/lib/LICENSE-drifty
deleted file mode 100644
index 18ab118..0000000
--- a/lib/LICENSE-drifty
+++ /dev/null
@@ -1,21 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2014 Drifty (http://drifty.com/)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
diff --git a/lib/codemirror/cm.defs b/lib/codemirror/cm.defs
index abb6d92..9677058 100644
--- a/lib/codemirror/cm.defs
+++ b/lib/codemirror/cm.defs
@@ -78,6 +78,7 @@
   'soy',
   'sql',
   'stex',
+  'swift',
   'tcl',
   'velocity',
   'verilog',
diff --git a/lib/js.defs b/lib/js.defs
index 9508c30..570ed1e 100644
--- a/lib/js.defs
+++ b/lib/js.defs
@@ -165,5 +165,5 @@
       '--js', '%s.js' % name,
       '&&', 'zip', '$OUT', '%s.html' % name, '%s.js' % name,
     ]),
-    out = '%s.vulcanized.zip',
+    out = '%s.vulcanized.zip' % name,
   )
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index c3c520b..e53b1a5 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -83,6 +83,9 @@
         display: flex;
         margin-left: var(--default-horizontal-margin);
       }
+      .feedback {
+        color: #b71c1c;
+      }
     </style>
     <gr-ajax auto url="/accounts/self/detail" last-response="{{account}}"></gr-ajax>
     <gr-ajax auto url="/config/server/info" last-response="{{config}}"></gr-ajax>
@@ -102,7 +105,8 @@
     </header>
     <main>
       <template is="dom-if" if="{{_showChangeListView}}" restamp="true">
-        <gr-change-list-view params="[[params]]"></gr-change-list-view>
+        <gr-change-list-view params="[[params]]"
+            logged-in="[[_computeLoggedIn(account)]]"></gr-change-list-view>
       </template>
       <template is="dom-if" if="{{_showDashboardView}}" restamp="true">
         <gr-dashboard-view params="[[params]]"></gr-dashboard-view>
@@ -117,18 +121,20 @@
     </main>
     <footer role="contentinfo">
       Powered by <a href="https://www.gerritcodereview.com/" target="_blank">Gerrit Code Review</a>
-      (<span>[[version]]</span>)
+      ([[version]])
       <span hidden$="[[!config.gerrit.report_bug_url]]">
         |
         <a href$="[[config.gerrit.report_bug_url]]" target="_blank">
           <span hidden$="[[!config.gerrit.report_bug_text]]">
             [[config.gerrit.report_bug_text]]
           </span>
-          <span hidden$="[[config.gerrit.report_bug_text]]">
-            Report Bug
-          </span>
+          <span hidden$="[[config.gerrit.report_bug_text]]">Report Bug</span>
         </a>
       </span>
+      |
+      <a class="feedback" href="http://goo.gl/forms/ETHmIH2Kga" target="_blank">
+        PolyGerrit Feedback
+      </a>
     </footer>
   </template>
   <script>
@@ -219,6 +225,10 @@
             window.location.pathname + window.location.hash));
       },
 
+      _computeLoggedIn: function(account) { // argument used for binding update only
+        return this.loggedIn;
+      },
+
     });
   })();
   </script>
diff --git a/polygerrit-ui/app/elements/gr-change-actions.html b/polygerrit-ui/app/elements/gr-change-actions.html
index c37a931..7cdab50 100644
--- a/polygerrit-ui/app/elements/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/gr-change-actions.html
@@ -24,6 +24,9 @@
       :host {
         display: block;
       }
+      button:before {
+        content: attr(data-label);
+      }
       button {
         background-color: #448aff;
         border: none;
@@ -33,7 +36,15 @@
         font: inherit;
         padding: .5em .75em;
       }
+      button[loading] {
+        cursor: wait;
+        opacity: .5;
+      }
+      button[loading]:before {
+        content: attr(data-loading-label);
+      }
       button[disabled] {
+        background-color: #555961;
         opacity: .5;
       }
     </style>
@@ -44,10 +55,11 @@
     <div>
       <template is="dom-repeat" items="[[_computeActionValues(_actions)]]" as="action">
         <button title$="[[action.title]]"
-            hidden$="[[!action.enabled]]"
+            disabled$="[[!action.enabled]]"
             data-action-key$="[[action.__key]]"
-            disabled$="[[_loading]]"
-            on-tap="_handleActionTap">[[action.label]]</button>
+            data-label$="[[action.label]]"
+            data-loading-label$="[[_computeLoadingLabel(action.__key)]]"
+            on-tap="_handleActionTap"></button>
       </template>
     </div>
   </template>
@@ -73,7 +85,7 @@
         },
         _loading: {
           type: Boolean,
-          value: false,
+          value: true,
         },
       },
 
@@ -102,6 +114,12 @@
         return result;
       },
 
+      _computeLoadingLabel: function(action) {
+        return {
+          'submit': 'Submitting...',
+        }[action];
+      },
+
       _handleActionTap: function(e) {
         e.preventDefault();
         var el = Polymer.dom(e).rootTarget;
@@ -112,12 +130,20 @@
       },
 
       _submitChange: function(endpoint, action) {
+        var buttonEl = this.$$('[data-action-key="' + action.__key + '"]');
+        buttonEl.setAttribute('loading', true);
+        buttonEl.disabled = true;
+
         this._send(action.method, {}, endpoint).then(
           function() {
             this.fire('reload-change', null, {bubbles: false});
+            buttonEl.setAttribute('loading', false);
+            buttonEl.disabled = false;
           }.bind(this)).catch(function(err) {
             alert('Oops. Something went wrong. Check the console and bug the ' +
                 'PolyGerrit team for assistance.');
+            buttonEl.setAttribute('loading', false);
+            buttonEl.disabled = false;
             throw err;
         });
       },
diff --git a/polygerrit-ui/app/elements/gr-change-list-item.html b/polygerrit-ui/app/elements/gr-change-list-item.html
index e2241919..9c77a06 100644
--- a/polygerrit-ui/app/elements/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/gr-change-list-item.html
@@ -17,6 +17,7 @@
 <link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../styles/gr-change-list-styles.html">
 <link rel="import" href="gr-account-link.html">
+<link rel="import" href="gr-change-star.html">
 <link rel="import" href="gr-date-formatter.html">
 
 <dom-module id="gr-change-list-item">
@@ -60,6 +61,9 @@
     <span class="cell keyboard">
       <span class="positionIndicator">&#x25b6;</span>
     </span>
+    <span class="cell star" hidden$="[[!showStar]]">
+      <gr-change-star change="{{change}}"></gr-change-star>
+    </span>
     <a class="cell subject" href$="[[changeURL]]">[[change.subject]]</a>
     <span class="cell status">[[_computeChangeStatusString(change)]]</span>
     <span class="cell owner">
@@ -73,8 +77,9 @@
       <span class="u-red"><span>-</span>[[change.deletions]]</span>
     </span>
     <span title="Code-Review"
-        class$="[[_computeCodeReviewClass(change.labels.Code_Review)]]">[[_computeCodeReviewLabel(change.labels.Code_Review)]]</span>
-    <span class="cell verified u-green" title="Verified">[[_computeVerifiedLabel(change.labels.Verified)]]</span>
+        class$="[[_computeLabelClass(change.labels.Code_Review)]]">[[_computeLabelValue(change.labels.Code_Review)]]</span>
+    <span title="Verified"
+        class$="[[_computeLabelClass(change.labels.Verified)]]">[[_computeLabelValue(change.labels.Verified)]]</span>
   </template>
   <script>
   (function() {
@@ -93,6 +98,10 @@
           type: String,
           computed: '_computeChangeURL(change._number)',
         },
+        showStar: {
+          type: Boolean,
+          value: false,
+        },
       },
 
       _computeChangeURL: function(changeNum) {
@@ -116,48 +125,47 @@
         return '';
       },
 
-      _computeCodeReviewClass: function(codeReview) {
+      _computeLabelClass: function(label) {
         // Mimic a Set.
         var classes = {
           'cell': true,
-          'codeReview': true,
+          'label': true,
         };
-        if (codeReview) {
-          if (codeReview.approved) {
+        if (label) {
+          if (label.approved) {
             classes['u-green'] = true;
           }
-          if (codeReview.value == 1) {
+          if (label.value == 1) {
             classes['u-monospace'] = true;
             classes['u-green'] = true;
-          } else if (codeReview.value == -1) {
+          } else if (label.value == -1) {
             classes['u-monospace'] = true;
             classes['u-red'] = true;
           }
+          if (label.rejected) {
+            classes['u-red'] = true;
+          }
         }
         return Object.keys(classes).sort().join(' ');
       },
 
-      _computeCodeReviewLabel: function(codeReview) {
-        if (!codeReview) { return ''; }
-        if (codeReview.approved) {
+      _computeLabelValue: function(label) {
+        if (!label) { return ''; }
+        if (label.approved) {
           return '✓';
         }
-        if (codeReview.value > 0) {
-          return '+' + codeReview.value;
+        if (label.rejected) {
+          return '✕';
         }
-        if (codeReview.value < 0) {
-          return codeReview.value;
+        if (label.value > 0) {
+          return '+' + label.value;
+        }
+        if (label.value < 0) {
+          return label.value;
         }
         return '';
       },
 
-      _computeVerifiedLabel: function(verified) {
-        if (verified && verified.approved) {
-          return '✓';
-        }
-        return ''
-      },
-
       _computeProjectURL: function(project) {
         return '/projects/' + project + ',dashboards/default';
       },
diff --git a/polygerrit-ui/app/elements/gr-change-list-view.html b/polygerrit-ui/app/elements/gr-change-list-view.html
index 5381552..aef69c5 100644
--- a/polygerrit-ui/app/elements/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/gr-change-list-view.html
@@ -56,9 +56,10 @@
         params="[[_computeQueryParams(query, offset)]]"
         last-response="{{_changes}}"
         loading="{{_loading}}"></gr-ajax>
-    <div class="loading" hidden$="{{!_loading}}">Loading...</div>
-    <div hidden$="{{_loading}}">
-      <gr-change-list changes="{{_changes}}"></gr-change-list>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <div hidden$="[[_loading]]">
+      <gr-change-list changes="{{_changes}}"
+          show-star="[[loggedIn]]"></gr-change-list>
       <nav>
         <a href$="[[_computeNavLink(query, offset, -1)]]"
            hidden$="[[_hidePrevArrow(offset)]]">&larr; Prev</a>
@@ -86,6 +87,14 @@
         },
 
         /**
+         * True when user is logged in.
+         */
+        loggedIn: {
+          type: Boolean,
+          value: false,
+        },
+
+        /**
          * Change objects loaded from the server.
          */
         _changes: Array,
diff --git a/polygerrit-ui/app/elements/gr-change-list.html b/polygerrit-ui/app/elements/gr-change-list.html
index 45a87fa..fd2b520 100644
--- a/polygerrit-ui/app/elements/gr-change-list.html
+++ b/polygerrit-ui/app/elements/gr-change-list.html
@@ -42,6 +42,7 @@
     <style include="gr-change-list-styles"></style>
     <div class="headerRow">
       <span class="topHeader keyboard"></span> <!-- keyboard position indicator -->
+      <span class="topHeader star" hidden$="[[!showStar]]"></span>
       <span class="topHeader subject">Subject</span>
       <span class="topHeader status">Status</span>
       <span class="topHeader owner">Owner</span>
@@ -49,15 +50,15 @@
       <span class="topHeader branch">Branch</span>
       <span class="topHeader updated">Updated</span>
       <span class="topHeader size">Size</span>
-      <span class="topHeader codeReview" title="Code-Review">CR</span>
-      <span class="topHeader verified" title="Verified">V</span>
+      <span class="topHeader label" title="Code-Review">CR</span>
+      <span class="topHeader label" title="Verified">V</span>
     </div>
     <template is="dom-repeat" items="{{groups}}" as="changeGroup" index-as="groupIndex">
       <template is="dom-if" if="[[_groupTitle(groupIndex)]]">
         <div class="groupHeader">[[_groupTitle(groupIndex)]]</div>
       </template>
       <template is="dom-repeat" items="[[changeGroup]]" as="change">
-        <gr-change-list-item change="[[change]]"></gr-change-list-item>
+        <gr-change-list-item change="[[change]]" show-star="[[showStar]]"></gr-change-list-item>
       </template>
     </template>
   </template>
@@ -100,6 +101,10 @@
           value: 0,
           observer: '_selectedIndexChanged',
         },
+        showStar: {
+          type: Boolean,
+          value: false,
+        },
         _boundKeyHandler: {
           type: Function,
           value: function() { return this._handleKey.bind(this); },
diff --git a/polygerrit-ui/app/elements/gr-change-star.html b/polygerrit-ui/app/elements/gr-change-star.html
new file mode 100644
index 0000000..9311aff
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-change-star.html
@@ -0,0 +1,88 @@
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="gr-request.html">
+
+<dom-module id="gr-change-star">
+  <template>
+    <style>
+      :host {
+        display: inline;
+      }
+      .starButton {
+        font-size: .95em;
+        margin-right: .25em;
+        cursor: pointer;
+        background-color: transparent;
+        border-color: transparent;
+      }
+      .star {
+        color: #fbc02d;
+      }
+      .unstar {
+        color: #666;
+      }
+    </style>
+    <button class="starButton" on-tap="_handleStarTap">
+      <span class="star" hidden$="[[!change.starred]]">&#9733;</span>
+      <span class="unstar" hidden$="[[change.starred]]">&#9734;</span>
+    </button>
+  </template>
+  <script>
+  (function() {
+    'use strict';
+
+    Polymer({
+      is: 'gr-change-star',
+
+      properties: {
+        change: {
+          type: Object,
+          notify: true,
+        },
+
+        _xhrPromise: Object,  // Used for testing.
+      },
+
+      _handleStarTap: function() {
+        var method = this.change.starred ? 'DELETE' : 'PUT';
+        this.set('change.starred', !this.change.starred);
+        this._send(method, this._restEndpoint()).catch(function(err) {
+          this.set('change.starred', !this.change.starred);
+          alert('Change couldn’t be starred. Check the console and contact ' +
+              'the PolyGerrit team for assistance.');
+          throw err;
+        }.bind(this));
+      },
+
+      _send: function(method, url) {
+        var xhr = document.createElement('gr-request');
+        this._xhrPromise = xhr.send({
+          method: method,
+          url: url,
+        });
+        return this._xhrPromise;
+      },
+
+      _restEndpoint: function() {
+        return '/accounts/self/starred.changes/' + this.change._number;
+      },
+
+    });
+  })();
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-change-view.html b/polygerrit-ui/app/elements/gr-change-view.html
index be63e59..6175803 100644
--- a/polygerrit-ui/app/elements/gr-change-view.html
+++ b/polygerrit-ui/app/elements/gr-change-view.html
@@ -18,6 +18,7 @@
 <link rel="import" href="gr-account-link.html">
 <link rel="import" href="gr-ajax.html">
 <link rel="import" href="gr-change-actions.html">
+<link rel="import" href="gr-change-star.html">
 <link rel="import" href="gr-date-formatter.html">
 <link rel="import" href="gr-file-list.html">
 <link rel="import" href="gr-linked-text.html">
@@ -149,6 +150,7 @@
       <div class="headerContainer">
         <div class="header">
           <span class="header-title">
+            <gr-change-star change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
             <a href$="[[_computeChangePath(_change._number)]]">[[_change._number]]</a><span>:</span>
             <span>[[_change.subject]]</span>
             <span class="changeStatus">[[_computeChangeStatus(_change.status)]]</span>
@@ -290,7 +292,10 @@
         _headerContainerEl: Object,
         _headerEl: Object,
         _projectConfig: Object,
-        _scrollHandler: Function,
+        _boundScrollHandler: {
+          type: Function,
+          value: function() { return this._handleBodyScroll.bind(this); },
+        },
         _boundKeyHandler: {
           type: Function,
           value: function() { return this._handleKey.bind(this); },
@@ -301,16 +306,15 @@
         app.accountReady.then(function() {
           this._loggedIn = app.loggedIn;
         }.bind(this));
-        this._scrollHandler = this._handleBodyScroll.bind(this);
       },
 
       attached: function() {
-        window.addEventListener('scroll', this._scrollHandler);
+        window.addEventListener('scroll', this._boundScrollHandler);
         document.body.addEventListener('keydown', this._boundKeyHandler);
       },
 
       detached: function() {
-        window.removeEventListener('scroll', this._scrollHandler);
+        window.removeEventListener('scroll', this._boundScrollHandler);
         document.body.removeEventListener('keydown', this._boundKeyHandler);
       },
 
@@ -333,6 +337,12 @@
         el.classList.toggle('pinned', window.scrollY >= top);
       },
 
+      _resetHeaderEl: function() {
+        var el = this._headerEl || this.$$('.header');
+        this._headerEl = el;
+        el.classList.remove('pinned');
+      },
+
       _handlePatchChange: function(e) {
         var patchNum = e.target.value;
         var currentPatchNum =
@@ -443,12 +453,14 @@
 
       _handleKey: function(e) {
         if (util.shouldSupressKeyboardShortcut(e)) { return; }
-        e.preventDefault();
+
         switch(e.keyCode) {
           case 65:  // 'a'
+            e.preventDefault();
             this.$.replyDropdown.open();
             break;
           case 85:  // 'u'
+            e.preventDefault();
             page.show('/');
             break;
         }
@@ -469,6 +481,7 @@
           // The patch number is reliant on the change detail request.
           detailCompletes.then(reloadPatchNumDependentResources);
         }
+        this._resetHeaderEl();
       },
 
     });
diff --git a/polygerrit-ui/app/elements/gr-dashboard-view.html b/polygerrit-ui/app/elements/gr-dashboard-view.html
index 88997f6..d04b9f6 100644
--- a/polygerrit-ui/app/elements/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/gr-dashboard-view.html
@@ -34,7 +34,7 @@
         url="/changes/"
         params="[[_computeQueryParams()]]"
         last-response="{{_results}}"></gr-ajax>
-    <gr-change-list groups="{{_results}}"
+    <gr-change-list groups="{{_results}}" show-star
         group-titles="[[_groupTitles]]"></gr-change-list>
   </template>
   <script>
diff --git a/polygerrit-ui/app/elements/gr-diff-view.html b/polygerrit-ui/app/elements/gr-diff-view.html
index a60460f..88ad3e7 100644
--- a/polygerrit-ui/app/elements/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/gr-diff-view.html
@@ -117,7 +117,6 @@
       </div>
     </h3>
     <gr-diff id="diff"
-        auto
         change-num="[[_changeNum]]"
         prefs="{{prefs}}"
         patch-range="[[_patchRange]]"
@@ -157,7 +156,7 @@
         _boundKeyHandler: {
           type: Function,
           value: function() { return this._handleKey.bind(this); },
-        }
+        },
       },
 
       attached: function() {
@@ -220,6 +219,8 @@
         if (!this._patchRange.patchNum) {
           return;
         }
+
+        this.$.diff.reload();
       },
 
       _computeDiffURL: function(changeNum, patchRange, path) {
diff --git a/polygerrit-ui/app/elements/gr-diff.html b/polygerrit-ui/app/elements/gr-diff.html
index 730e937..a4b516e 100644
--- a/polygerrit-ui/app/elements/gr-diff.html
+++ b/polygerrit-ui/app/elements/gr-diff.html
@@ -23,6 +23,10 @@
 <dom-module id="gr-diff">
   <template>
     <style>
+      .loading {
+        padding: 0 var(--default-horizontal-margin) 1em;
+        color: #666;
+      }
       .header {
         display: flex;
         margin: 0 var(--default-horizontal-margin) .75em;
@@ -52,7 +56,8 @@
     <gr-ajax id="diffXHR"
         url="[[_computeDiffPath(changeNum, patchRange.patchNum, path)]]"
         params="[[_computeDiffQueryParams(patchRange.basePatchNum)]]"
-        last-response="{{_diffResponse}}"></gr-ajax>
+        last-response="{{_diffResponse}}"
+        loading="{{_loading}}"></gr-ajax>
     <gr-ajax id="baseCommentsXHR"
         url="[[_computeCommentsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
     <gr-ajax id="commentsXHR"
@@ -61,50 +66,52 @@
         url="[[_computeDraftsPath(changeNum, patchRange.basePatchNum)]]"></gr-ajax>
     <gr-ajax id="draftsXHR"
         url="[[_computeDraftsPath(changeNum, patchRange.patchNum)]]"></gr-ajax>
-
-    <div class="header">
-      <gr-patch-range-select
-          path="[[path]]"
-          change-num="[[changeNum]]"
-          patch-range="[[patchRange]]"
-          available-patches="[[availablePatches]]"></gr-patch-range-select>
-      <div class="contextControl" hidden$="[[!prefs.context]]" hidden>
-        Context:
-          <select id="contextSelect" on-change="_handleContextSelectChange">
-            <option value="3">3 lines</option>
-            <option value="10">10 lines</option>
-            <option value="25">25 lines</option>
-            <option value="50">50 lines</option>
-            <option value="75">75 lines</option>
-            <option value="100">100 lines</option>
-            <option value="-1">Whole file</option>
-          </select>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <div hidden$="[[_loading]]" hidden>
+      <div class="header">
+        <gr-patch-range-select
+            path="[[path]]"
+            change-num="[[changeNum]]"
+            patch-range="[[patchRange]]"
+            available-patches="[[availablePatches]]"></gr-patch-range-select>
+        <div class="contextControl" hidden$="[[!prefs.context]]" hidden>
+          Context:
+            <select id="contextSelect" on-change="_handleContextSelectChange">
+              <option value="3">3 lines</option>
+              <option value="10">10 lines</option>
+              <option value="25">25 lines</option>
+              <option value="50">50 lines</option>
+              <option value="75">75 lines</option>
+              <option value="100">100 lines</option>
+              <option value="-1">Whole file</option>
+            </select>
+        </div>
       </div>
-    </div>
 
-    <div class="diffContainer">
-      <gr-diff-side id="leftDiff"
-          change-num="[[changeNum]]"
-          patch-num="[[patchRange.basePatchNum]]"
-          path="[[path]]"
-          content="{{_diff.leftSide}}"
-          width="[[sideWidth]]"
-          can-comment="[[_loggedIn]]"
-          on-expand-context="_handleExpandContext"
-          on-thread-height-change="_handleThreadHeightChange"
-          on-add-draft="_handleAddDraft"
-          on-remove-thread="_handleRemoveThread"></gr-diff-side>
-      <gr-diff-side id="rightDiff"
-          change-num="[[changeNum]]"
-          patch-num="[[patchRange.patchNum]]"
-          path="[[path]]"
-          content="{{_diff.rightSide}}"
-          width="[[sideWidth]]"
-          can-comment="[[_loggedIn]]"
-          on-expand-context="_handleExpandContext"
-          on-thread-height-change="_handleThreadHeightChange"
-          on-add-draft="_handleAddDraft"
-          on-remove-thread="_handleRemoveThread"></gr-diff-side>
+      <div class="diffContainer">
+        <gr-diff-side id="leftDiff"
+            change-num="[[changeNum]]"
+            patch-num="[[patchRange.basePatchNum]]"
+            path="[[path]]"
+            content="{{_diff.leftSide}}"
+            width="[[sideWidth]]"
+            can-comment="[[_loggedIn]]"
+            on-expand-context="_handleExpandContext"
+            on-thread-height-change="_handleThreadHeightChange"
+            on-add-draft="_handleAddDraft"
+            on-remove-thread="_handleRemoveThread"></gr-diff-side>
+        <gr-diff-side id="rightDiff"
+            change-num="[[changeNum]]"
+            patch-num="[[patchRange.patchNum]]"
+            path="[[path]]"
+            content="{{_diff.rightSide}}"
+            width="[[sideWidth]]"
+            can-comment="[[_loggedIn]]"
+            on-expand-context="_handleExpandContext"
+            on-thread-height-change="_handleThreadHeightChange"
+            on-add-draft="_handleAddDraft"
+            on-remove-thread="_handleRemoveThread"></gr-diff-side>
+      </div>
     </div>
   </template>
   <script>
@@ -121,10 +128,6 @@
        */
 
       properties: {
-        auto: {
-          type: Boolean,
-          value: false,
-        },
         availablePatches: Array,
         changeNum: String,
         /*
@@ -182,13 +185,16 @@
           type: Boolean,
           value: false,
         },
+        _loading: {
+          type: Boolean,
+          value: true,
+        },
         _diffRequestsPromise: Object,  // Used for testing.
         _diffPreferencesPromise: Object,  // Used for testing.
       },
 
       observers: [
         '_prefsChanged(prefs.*)',
-        '_diffOptionsChanged(changeNum, patchRange, path)',
       ],
 
       ready: function() {
@@ -202,28 +208,14 @@
         this.$.rightDiff.scrollToLine(lineNum);
       },
 
-      _prefsChanged: function(changeRecord) {
-        var prefs = changeRecord.base;
-
-        this.$.contextSelect.value = prefs.context;
-
-        if (this._initialRenderComplete) {
-          this._render();
-        }
-
-        this._resolvePrefsReady(prefs);
-      },
-
-      _diffOptionsChanged: function(changeNum, patchRange, path) {
-        if (!this.auto) { return; }
-
+      reload: function(changeNum, patchRange, path) {
         var promises = [
           this._prefsReady,
           this.$.diffXHR.generateRequest().completes
         ];
 
-        var basePatchNum = patchRange.basePatchNum;
-        var patchNum = patchRange.patchNum;
+        var basePatchNum = this.patchRange.basePatchNum;
+        var patchNum = this.patchRange.patchNum;
 
         app.accountReady.then(function() {
           promises.push(this._getCommentsAndDrafts(basePatchNum, app.loggedIn));
@@ -237,6 +229,18 @@
         }.bind(this));
       },
 
+      _prefsChanged: function(changeRecord) {
+        var prefs = changeRecord.base;
+
+        this.$.contextSelect.value = prefs.context;
+
+        if (this._initialRenderComplete) {
+          this._render();
+        }
+
+        this._resolvePrefsReady(prefs);
+      },
+
       _render: function() {
         this._groupCommentsAndDrafts();
         this._processContent();
diff --git a/polygerrit-ui/app/elements/gr-reply-dropdown.html b/polygerrit-ui/app/elements/gr-reply-dropdown.html
index d136d43..522018bf 100644
--- a/polygerrit-ui/app/elements/gr-reply-dropdown.html
+++ b/polygerrit-ui/app/elements/gr-reply-dropdown.html
@@ -35,6 +35,7 @@
     iron-dropdown {
       background-color: #fff;
       box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+      max-width: 40em;
     }
     button {
       background-color: #f1f2f3;
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index 9b44d31..5a96b6c 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -16,7 +16,8 @@
 <dom-module id="gr-change-list-styles">
   <template>
     <style>
-      .keyboard {
+      .keyboard,
+      .star {
         width: 2em;
       }
       .subject {
@@ -50,14 +51,10 @@
         width: 9em;
         text-align: right;
       }
-      .codeReview {
+      .label {
         width: 2.6em;
         text-align: center;
       }
-      .verified {
-        width: 2em;
-        text-align: center;
-      }
     </style>
   </template>
 </dom-module>
diff --git a/polygerrit-ui/app/test/gr-change-star-test.html b/polygerrit-ui/app/test/gr-change-star-test.html
new file mode 100644
index 0000000..25eacf4
--- /dev/null
+++ b/polygerrit-ui/app/test/gr-change-star-test.html
@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-star</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<script src="../../bower_components/page/page.js"></script>
+<script src="../scripts/fake-app.js"></script>
+<script src="../scripts/util.js"></script>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../elements/gr-change-star.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-change-star></gr-change-star>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-star tests', function() {
+    var element;
+    var server;
+
+    setup(function() {
+      element = fixture('basic');
+      element.change = {
+        _number: 2,
+        starred: true,
+      };
+
+      server = sinon.fakeServer.create();
+      server.respondWith(
+        'PUT',
+        '/accounts/self/starred.changes/2',
+        [
+          204,
+          { 'Content-Type': 'application/json' },
+          ''
+        ]
+      );
+
+      server.respondWith(
+        'DELETE',
+        '/accounts/self/starred.changes/2',
+        [
+          204,
+          { 'Content-Type': 'application/json' },
+          ''
+        ]
+      );
+    });
+
+    teardown(function() {
+      server.restore();
+    });
+
+    function isVisible(el) {
+      assert.ok(el);
+      return getComputedStyle(el).getPropertyValue('display') != 'none';
+    }
+
+    test('star visibility states', function() {
+      element.set('change.starred', true);
+      assert.isTrue(isVisible(element.$$('.star')), 'star is visible');
+      assert.isFalse(isVisible(element.$$('.unstar')), 'unstar is not visible');
+
+      element.set('change.starred', false);
+      assert.isTrue(isVisible(element.$$('.unstar')), 'unstar is visible');
+      assert.isFalse(isVisible(element.$$('.star')), 'star is not visible');
+    });
+
+    test('starring', function(done) {
+      element.set('change.starred', false);
+      MockInteractions.tap(element.$$('button'));
+
+      server.respond();
+
+      element._xhrPromise.then(function(req) {
+        assert.equal(req.status, 204);
+        assert.equal(req.url, '/accounts/self/starred.changes/2');
+        assert.equal(element.change.starred, true);
+        done();
+      });
+    });
+
+    test('unstarring', function(done) {
+      element.set('change.starred', true);
+      MockInteractions.tap(element.$$('button'));
+
+      server.respond();
+
+      element._xhrPromise.then(function(req) {
+        assert.equal(req.status, 204);
+        assert.equal(req.url, '/accounts/self/starred.changes/2');
+        assert.equal(element.change.starred, false);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/gr-diff-test.html b/polygerrit-ui/app/test/gr-diff-test.html
index 6ed4a8f..e201662 100644
--- a/polygerrit-ui/app/test/gr-diff-test.html
+++ b/polygerrit-ui/app/test/gr-diff-test.html
@@ -29,7 +29,7 @@
 
 <test-fixture id="basic">
   <template>
-    <gr-diff auto></gr-diff>
+    <gr-diff></gr-diff>
   </template>
 </test-fixture>
 
@@ -211,6 +211,7 @@
         patchNum: 1,
       };
 
+      element.reload();
       server.respond();
 
       element._diffRequestsPromise.then(function() {
@@ -228,6 +229,7 @@
         patchNum: 2,
       };
 
+      element.reload();
       server.respond();
 
       element._diffRequestsPromise.then(function() {
@@ -247,6 +249,7 @@
         patchNum: 2,
       };
 
+      element.reload();
       server.respond();
 
       // Allow events to fire and the threads to render.
diff --git a/polygerrit-ui/app/test/gr-diff-view-test.html b/polygerrit-ui/app/test/gr-diff-view-test.html
index 99fae59b..fd25ef5 100644
--- a/polygerrit-ui/app/test/gr-diff-view-test.html
+++ b/polygerrit-ui/app/test/gr-diff-view-test.html
@@ -45,37 +45,6 @@
       element.$.diff.auto = false;
     });
 
-    // https://github.com/PolymerElements/iron-test-helpers/issues/33
-    function keyboardEventFor(type, keyIdentifier) {
-      var event = new CustomEvent(type, {
-        bubbles: true,
-        cancelable: true
-      });
-
-      event.keyIdentifier = keyIdentifier;
-
-      return event;
-    }
-
-    function keyEventOn(target, type, keyIdentifier) {
-      target.dispatchEvent(keyboardEventFor(type, keyIdentifier));
-    }
-
-    function keyDownOn(target, keyIdentifier) {
-      keyEventOn(target, 'keydown', keyIdentifier);
-    }
-
-    function keyUpOn(target, keyIdentifier) {
-      keyEventOn(target, 'keyup', keyIdentifier);
-    }
-
-    function pressAndReleaseKeyIdentifierOn(target, keyIdentifier) {
-      keyDownOn(target, keyIdentifier);
-      Polymer.Base.async(function() {
-        keyUpOn(target, keyIdentifier);
-      }, 1);
-    }
-
     test('keyboard shortcuts', function() {
       element._changeNum = '42';
       element._patchRange = {
@@ -89,22 +58,22 @@
       assert(showStub.lastCall.calledWithExactly('/c/42'),
           'Should navigate to /c/42');
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005D');  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
       assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
           'Should navigate to /c/42/10/wheatley.md');
       element._path = 'wheatley.md';
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
           'Should navigate to /c/42/10/glados.txt');
       element._path = 'glados.txt';
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
           'Should navigate to /c/42/10/chell.go');
       element._path = 'chell.go';
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42'),
           'Should navigate to /c/42');
 
@@ -126,22 +95,22 @@
       assert(showStub.lastCall.calledWithExactly('/c/42'),
           'Should navigate to /c/42');
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005D');  // ']'
+      MockInteractions.pressAndReleaseKeyOn(element, 221);  // ']'
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
           'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/glados.txt'),
           'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42/5..10/chell.go'),
           'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
-      pressAndReleaseKeyIdentifierOn(element, '\U+005B');  // '['
+      MockInteractions.pressAndReleaseKeyOn(element, 219);  // '['
       assert(showStub.lastCall.calledWithExactly('/c/42'),
           'Should navigate to /c/42');
     });
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index e415b2e..eff75db 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -29,6 +29,7 @@
     'gr-change-actions-test.html',
     'gr-change-list-item-test.html',
     'gr-change-list-test.html',
+    'gr-change-star-test.html',
     'gr-change-view-test.html',
     'gr-date-formatter-test.html',
     'gr-diff-comment-test.html',