Merge "Remove comments about OpenJDK bug 100167"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 6bcf851..a3a813e 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -3541,6 +3541,15 @@
 +
 Default is zero, no limit.
 
+[[receive.maxBatchCommits]]receive.maxBatchCommits::
++
+The maximum number of commits that Gerrit allows to be pushed in a batch
+directly to a branch when link:user-upload.html#bypass_review[bypassing review].
+This limit can be bypassed if a user link:user-upload.html#skip_validation[skips
+validation].
++
+Default is 10000.
+
 [[receive.maxObjectSizeLimit]]receive.maxObjectSizeLimit::
 +
 Maximum allowed Git object size that 'receive-pack' will accept.
diff --git a/Documentation/dev-plugins-pg.txt b/Documentation/dev-plugins-pg.txt
index 9d36758..e1bf39e 100644
--- a/Documentation/dev-plugins-pg.txt
+++ b/Documentation/dev-plugins-pg.txt
@@ -45,14 +45,14 @@
 decoration case, a hook is set with a `content` attribute that points to the DOM
 element.
 
-1. Get the DOM hook API instance via `plugin.getDomHook(endpointName)`
+1. Get the DOM hook API instance via `plugin.hook(endpointName)`
 2. Set up an `onAttached` callback
 3. Callback is called when the hook element is created and inserted into DOM
 4. Use element.content to get UI element
 
 ``` js
 Gerrit.install(function(plugin) {
-  const domHook = plugin.getDomHook('reply-text');
+  const domHook = plugin.hook('reply-text');
   domHook.onAttached(element => {
     if (!element.content) { return; }
     // element.content is a reply dialog text area.
@@ -70,7 +70,7 @@
 
 ``` js
 Gerrit.install(function(plugin) {
-  const domHook = plugin.getDomHook('reply-text');
+  const domHook = plugin.hook('reply-text');
   domHook.onAttached(element => {
     if (!element.content) { return; }
     element.content.style.border = '1px red dashed';
@@ -86,7 +86,7 @@
 
 ``` js
 Gerrit.install(function(plugin) {
-  const domHook = plugin.getDomHook('header-title', {replace: true});
+  const domHook = plugin.hook('header-title', {replace: true});
   domHook.onAttached(element => {
     element.appendChild(document.createElement('my-site-header'));
   });
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 26193f4..2f6a7d6 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -359,6 +359,13 @@
   git merge stable
 ----
 
+[[update-api-version-in-bazlets-repository]]
+
+Bazlets is used by gerrit plugins to simplify build process. To allow the
+new released version to be used by gerrit plugins,
+link:https://gerrit.googlesource.com/bazlets/+/master/gerrit_api.bzl#8[gerrit_api.bzl]
+must reference the new version. Upload a change to bazlets repository with
+api version upgrade.
 
 GERRIT
 ------
diff --git a/Documentation/error-messages.txt b/Documentation/error-messages.txt
index 2632254..ca8dc75 100644
--- a/Documentation/error-messages.txt
+++ b/Documentation/error-messages.txt
@@ -32,6 +32,7 @@
 * link:error-prohibited-by-gerrit.html[prohibited by Gerrit]
 * link:error-project-not-found.html[Project not found: ...]
 * link:error-same-change-id-in-multiple-changes.html[same Change-Id in multiple changes]
+* link:error-too-many-commits.html[too many commits]
 * link:error-upload-denied.html[Upload denied for project \'...']
 * link:error-not-allowed-to-upload-merges.html[you are not allowed to upload merges]
 
diff --git a/Documentation/error-too-many-commits.txt b/Documentation/error-too-many-commits.txt
new file mode 100644
index 0000000..3e16220
--- /dev/null
+++ b/Documentation/error-too-many-commits.txt
@@ -0,0 +1,20 @@
+= too many commits
+
+This error occurs when a push directly to a branch
+link:user-upload.html#bypass_review[bypassing review] contains more commits than
+the server is able to validate in a single batch.
+
+The recommended way to avoid this message is to use the
+link:user-upload.html#skip_validation[`skip-validation` push option]. Depending
+on the number of commits, it may also be feasible to split the push into smaller
+batches.
+
+The actual limit is controlled by a
+link:config-gerrit.html#receive.maxBatchCommits[server config option].
+
+GERRIT
+------
+Part of link:error-messages.html[Gerrit Error Messages]
+
+SEARCHBOX
+---------
diff --git a/Documentation/pgm-LocalUsernamesToLowerCase.txt b/Documentation/pgm-LocalUsernamesToLowerCase.txt
index e0fe1b3..03aaabf 100644
--- a/Documentation/pgm-LocalUsernamesToLowerCase.txt
+++ b/Documentation/pgm-LocalUsernamesToLowerCase.txt
@@ -7,7 +7,7 @@
 == SYNOPSIS
 [verse]
 --
-_java_ -jar gerrit.war _LocalUsernamesToLowerCase
+_java_ -jar gerrit.war _LocalUsernamesToLowerCase_
   -d <SITE_PATH>
 --
 
diff --git a/Documentation/project-configuration.txt b/Documentation/project-configuration.txt
index 54ddcff..c95cf2c 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -224,6 +224,7 @@
 The defined maximum Git object size limit is inherited by any child
 project.
 
+[[require-signed-off-by]]
 === Require Signed-off-by
 
 The `Require Signed-off-by in commit message` option defines whether a
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index fad4b9c..79c7a1a 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1077,6 +1077,36 @@
 
 If the change had no assignee the response is "`204 No Content`".
 
+[[get-pure-revert]]
+=== Get Pure Revert
+--
+'GET /changes/link:#change-id[\{change-id\}]/pure_revert'
+--
+
+Check if the given change is a pure revert of the change it references in `revertOf`.
+Optionally, the query parameter `o` can be passed in to specify a commit (SHA1 in
+40 digit hex representation) to check against. It takes precedence over `revertOf`.
+If the change has no reference in `revertOf`, the parameter is mandatory.
+
+As response a link:#pure-revert-info[PureRevertInfo] entity is returned.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/pure_revert?o=247bccf56ae47634650bcc08b8aa784c3580ccas HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "is_pure_revert" : false
+  }
+----
+
 [[abandon-change]]
 === Abandon Change
 --
@@ -6557,6 +6587,16 @@
 of recipient type to link:#notify-info[NotifyInfo] entity.
 |=============================
 
+[[pure-revert-info]]
+=== PureRevertInfo
+The `PureRevertInfo` entity describes the result of a pure revert check.
+
+[options="header",cols="1,6"]
+|======================
+|Field Name      |Description
+|`is_pure_revert`  |Outcome of the check as boolean.
+|======================
+
 [[push-certificate-info]]
 === PushCertificateInfo
 The `PushCertificateInfo` entity contains information about a push
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 210f9e9..556a8bb 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -517,6 +517,44 @@
 make undesired changes to the public repository.
 
 
+[[skip_validation]]
+=== Skip Validation
+
+Even when a user has permission to push directly to a branch
+link:#bypass_review[bypassing review], by default Gerrit will still validate any
+new commits, for example to check author/committer identities, and run
+link:config-validation.html#new-commit-validation[validation plugins]. This
+behavior can be bypassed with a push option:
+
+----
+git push -o skip-validation HEAD:master
+----
+
+Using the `skip-validation` option requires the user to have a specific set
+of permissions, *in addition* to those permissions already required to bypass
+review:
+
+* link:access-control.html#category_forge_author[Forge Author]
+* link:access-control.html#category_forge_committer[Forge Committer]
+* link:access-control.html#category_forge_server[Forge Server]
+* link:access-control.html#category_push_merge[Push Merge Commits]
+
+Plus these additional requirements on the project:
+
+* Project must not link:project-configuration.html#require-signed-off-by[require
+Signed-off-by].
+* Project must not have `refs/meta/reject-commits`.
+
+This option only applies when pushing directly to a branch bypassing review.
+Validation also occurs when pushing new changes for review, and that type of
+validation cannot be skipped.
+
+The `skip-validation` option is always required when pushing
+link:error-too-many-commits.html[more than a certain number of commits]. This is
+the recommended approach when pushing lots of old history, since some validators
+would require rewriting history in order to make them pass.
+
+
 [[auto_merge]]
 === Auto-Merge during Push
 
diff --git a/WORKSPACE b/WORKSPACE
index 5057012..d76b8d4 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -6,9 +6,9 @@
 
 http_archive(
     name = "io_bazel_rules_closure",
-    sha256 = "af1f5a31b8306faed9d09a38c8e2c1d6afc4c4a2dada3b5de11cceae8c7f4596",
-    strip_prefix = "rules_closure-f68d4b5a55c04ee50a3196590dce1ca8e7dbf438",
-    url = "https://bazel-mirror.storage.googleapis.com/github.com/bazelbuild/rules_closure/archive/f68d4b5a55c04ee50a3196590dce1ca8e7dbf438.tar.gz",  # 2017-05-05
+    sha256 = "25f5399f18d8bf9ce435f85c6bbf671ec4820bc4396b3022cc5dc4bc66303609",
+    strip_prefix = "rules_closure-0.4.2",
+    url = "https://bazel-mirror.storage.googleapis.com/github.com/bazelbuild/rules_closure/archive/0.4.2.tar.gz",  # 2017-08-29
 )
 
 # File is specific to Polymer and copied from the Closure Github -- should be
@@ -608,8 +608,8 @@
 
 maven_jar(
     name = "dropwizard_core",
-    artifact = "io.dropwizard.metrics:metrics-core:3.2.2",
-    sha1 = "cd9886f498ee2ab2d994f0c779e5553b2c450416",
+    artifact = "io.dropwizard.metrics:metrics-core:3.2.4",
+    sha1 = "36af4975e38bb39686a63ba5139dce8d3f410669",
 )
 
 # When updading Bouncy Castle, also update it in bazlets.
diff --git a/contrib/abandon_stale.py b/contrib/abandon_stale.py
index fb9eb12..d734cd1 100755
--- a/contrib/abandon_stale.py
+++ b/contrib/abandon_stale.py
@@ -191,7 +191,7 @@
 
         try:
             gerrit.post("/changes/" + change_id + "/abandon",
-                        data='{"message" : "%s"}' % abandon_message)
+                        data={"message" : "%s" % abandon_message})
             abandoned += 1
         except Exception as e:
             errors += 1
diff --git a/fake_pom.xml b/fake_pom.xml
index e8910b6..6ec45e5 100644
--- a/fake_pom.xml
+++ b/fake_pom.xml
@@ -53,6 +53,9 @@
       <name>Logan Hanks</name>
     </developer>
     <developer>
+      <name>Luca Milanesio</name>
+    </developer>
+    <developer>
       <name>Martin Fick</name>
     </developer>
     <developer>
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
index 6edb3f0..43477ae 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/GlobalPluginConfig.java
@@ -25,7 +25,7 @@
 @Retention(RUNTIME)
 @Repeatable(GlobalPluginConfigs.class)
 public @interface GlobalPluginConfig {
-  /** Name of the plugin, corresponding to {@code $site/etc/@pluginName.comfig}. */
+  /** Name of the plugin, corresponding to {@code $site/etc/@pluginName.config}. */
   String pluginName();
 
   /** @see GerritConfig#name() */
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index b7d368a..cb33959 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -45,6 +45,7 @@
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
@@ -90,6 +91,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -533,9 +535,7 @@
     Set<String> currentEmails = getEmails();
     for (String email : emails) {
       assertThat(currentEmails).doesNotContain(email);
-      EmailInput input = new EmailInput();
-      input.email = email;
-      input.noConfirmation = true;
+      EmailInput input = newEmailInput(email);
       gApi.accounts().self().addEmail(input);
       accountIndexedCounter.assertReindexOf(admin);
     }
@@ -560,9 +560,7 @@
             // Non-supported TLD  (see tlds-alpha-by-domain.txt)
             "new.email@example.africa");
     for (String email : emails) {
-      EmailInput input = new EmailInput();
-      input.email = email;
-      input.noConfirmation = true;
+      EmailInput input = newEmailInput(email);
       try {
         gApi.accounts().self().addEmail(input);
         fail("Expected BadRequestException for invalid email address: " + email);
@@ -576,20 +574,41 @@
   @Test
   public void cannotAddNonConfirmedEmailWithoutModifyAccountPermission() throws Exception {
     TestAccount account = accountCreator.create(name("user"));
-    EmailInput input = new EmailInput();
-    input.email = "test@test.com";
-    input.noConfirmation = true;
+    EmailInput input = newEmailInput("test@test.com");
     setApiUser(user);
     exception.expect(AuthException.class);
     gApi.accounts().id(account.username).addEmail(input);
   }
 
   @Test
+  public void cannotAddEmailAddressUsedByAnotherAccount() throws Exception {
+    String email = "new.email@example.com";
+    EmailInput input = newEmailInput(email);
+    gApi.accounts().self().addEmail(input);
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("Identity 'mailto:" + email + "' in use by another account");
+    gApi.accounts().id(user.username).addEmail(input);
+  }
+
+  @Test
+  @GerritConfig(
+    name = "auth.registerEmailPrivateKey",
+    value = "HsOc6l+2lhS9G7sE/RsnS7Z6GJjdRDX14co="
+  )
+  public void addEmailSendsConfirmationEmail() throws Exception {
+    String email = "new.email@example.com";
+    EmailInput input = newEmailInput(email, false);
+    gApi.accounts().self().addEmail(input);
+
+    assertThat(sender.getMessages()).hasSize(1);
+    Message m = sender.getMessages().get(0);
+    assertThat(m.rcpt()).containsExactly(new Address(email));
+  }
+
+  @Test
   public void deleteEmail() throws Exception {
     String email = "foo.bar@example.com";
-    EmailInput input = new EmailInput();
-    input.email = email;
-    input.noConfirmation = true;
+    EmailInput input = newEmailInput(email);
     gApi.accounts().self().addEmail(input);
 
     resetCurrentApiUser();
@@ -1655,6 +1674,17 @@
     assertThat(newAccount.getMetaId()).isEqualTo(getMetaId(accountId));
   }
 
+  private EmailInput newEmailInput(String email, boolean noConfirmation) {
+    EmailInput input = new EmailInput();
+    input.email = email;
+    input.noConfirmation = noConfirmation;
+    return input;
+  }
+
+  private EmailInput newEmailInput(String email) {
+    return newEmailInput(email, true);
+  }
+
   private String getMetaId(Account.Id accountId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo);
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 bfa21cb..4ce412c 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
@@ -19,6 +19,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
 import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
@@ -31,6 +32,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -81,11 +83,13 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -3127,6 +3131,150 @@
     gApi.changes().id(r.getChangeId()).setMessage(getCommitMessage(r.getChangeId()));
   }
 
+  @Test
+  public void fourByteEmoji() throws Exception {
+    // U+1F601 GRINNING FACE WITH SMILING EYES
+    String smile = new String(Character.toChars(0x1f601));
+    assertThat(smile).isEqualTo("😁");
+    assertThat(smile).hasLength(2); // Thanks, Java.
+    assertThat(smile.getBytes(UTF_8)).hasLength(4);
+
+    String subject = "A happy change " + smile;
+    PushOneCommit.Result r =
+        pushFactory
+            .create(db, admin.getIdent(), testRepo, subject, FILE_NAME, FILE_CONTENT)
+            .to("refs/for/master");
+    r.assertOkStatus();
+    String id = r.getChangeId();
+
+    ReviewInput ri = ReviewInput.approve();
+    ri.message = "I like it " + smile;
+    ReviewInput.CommentInput ci = new ReviewInput.CommentInput();
+    ci.path = FILE_NAME;
+    ci.side = Side.REVISION;
+    ci.message = "Good " + smile;
+    ri.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(ci));
+    gApi.changes().id(id).current().review(ri);
+
+    ChangeInfo info =
+        gApi.changes()
+            .id(id)
+            .get(
+                EnumSet.of(
+                    ListChangesOption.MESSAGES,
+                    ListChangesOption.CURRENT_COMMIT,
+                    ListChangesOption.CURRENT_REVISION));
+    assertThat(info.subject).isEqualTo(subject);
+    assertThat(Iterables.getLast(info.messages).message).endsWith(ri.message);
+    assertThat(Iterables.getOnlyElement(info.revisions.values()).commit.message)
+        .startsWith(subject);
+
+    List<CommentInfo> comments =
+        Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
+    assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
+  }
+
+  @Test
+  public void pureRevertReturnsTrueForPureRevert() throws Exception {
+    PushOneCommit.Result r = createChange();
+    merge(r);
+    String revertId = gApi.changes().id(r.getChangeId()).revert().get().id;
+    // Without query parameter
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    // With query parameter
+    assertThat(
+            gApi.changes()
+                .id(revertId)
+                .pureRevert(getRemoteHead().toObjectId().name())
+                .isPureRevert)
+        .isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnContentChange() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+    // Create a revert and expect pureRevert to be true
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+
+    // Create a new PS and expect pureRevert to be false
+    PushOneCommit.Result result = amendChange(revertId);
+    result.assertOkStatus();
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertParameterTakesPrecedence() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String oldHead = getRemoteHead().toObjectId().name();
+
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r2.getChangeId()).revert().get().changeId;
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+    assertThat(gApi.changes().id(revertId).pureRevert(oldHead).isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseOnInvalidInput() throws Exception {
+    PushOneCommit.Result r1 = createChange();
+    merge(r1);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid object ID");
+    gApi.changes().id(createChange().getChangeId()).pureRevert("invalid id");
+  }
+
+  @Test
+  public void pureRevertReturnsTrueWithCleanRebase() throws Exception {
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+
+    PushOneCommit.Result r2 = createChange("commit message", "b.txt", "content2");
+    merge(r2);
+
+    String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
+    // Rebase revert onto HEAD
+    gApi.changes().id(revertId).rebase();
+    // Check that pureRevert is true which implies that the commit can be rebased onto the original
+    // commit.
+    assertThat(gApi.changes().id(revertId).pureRevert().isPureRevert).isTrue();
+  }
+
+  @Test
+  public void pureRevertReturnsFalseWithRebaseConflict() throws Exception {
+    // Create an initial commit to serve as claimed original
+    PushOneCommit.Result r1 = createChange("commit message", "a.txt", "content1");
+    merge(r1);
+    String claimedOriginal = getRemoteHead().toObjectId().name();
+
+    // Change contents of the file to provoke a conflict
+    merge(createChange("commit message", "a.txt", "content2"));
+
+    // Create a commit that we can revert
+    PushOneCommit.Result r2 = createChange("commit message", "a.txt", "content3");
+    merge(r2);
+
+    // Create a revert of r2
+    String revertR3Id = gApi.changes().id(r2.getChangeId()).revert().id();
+    // Assert that the change is a pure revert of it's 'revertOf'
+    assertThat(gApi.changes().id(revertR3Id).pureRevert().isPureRevert).isTrue();
+    // Assert that the change is not a pure revert of claimedOriginal because pureRevert is trying
+    // to rebase this on claimed original, which fails.
+    PureRevertInfo pureRevert = gApi.changes().id(revertR3Id).pureRevert(claimedOriginal);
+    assertThat(pureRevert.isPureRevert).isFalse();
+  }
+
+  @Test
+  public void pureRevertThrowsExceptionWhenChangeIsNotARevertAndNoIdProvided() throws Exception {
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("no ID was provided and change isn't a revert");
+    gApi.changes().id(createChange().getChangeId()).pureRevert();
+  }
+
   private String getCommitMessage(String changeId) throws RestApiException, IOException {
     return gApi.changes().id(changeId).current().file("/COMMIT_MSG").content().asString();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 6face43..0dc1389 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -23,10 +23,12 @@
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
 import static com.google.gerrit.extensions.common.EditInfoSubject.assertThat;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.project.Util.category;
 import static com.google.gerrit.server.project.Util.value;
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
@@ -35,6 +37,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
@@ -62,10 +65,12 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.receive.ReceiveConstants;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -1766,6 +1771,29 @@
     assertThat(getPublishedComments(r.getChangeId())).isEmpty();
   }
 
+  @GerritConfig(name = "receive.maxBatchCommits", value = "2")
+  @Test
+  public void maxBatchCommits() throws Exception {
+    List<RevCommit> commits = new ArrayList<>();
+    commits.addAll(initChanges(2));
+    String master = "refs/heads/master";
+    assertPushOk(pushHead(testRepo, master), master);
+
+    commits.addAll(initChanges(3));
+    assertPushRejected(pushHead(testRepo, master), master, "too many commits");
+
+    grantSkipValidation(project, master, SystemGroupBackend.REGISTERED_USERS);
+    PushResult r =
+        pushHead(testRepo, master, false, false, ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+    assertPushOk(r, master);
+
+    // No open changes; branch was advanced.
+    String q = commits.stream().map(ObjectId::name).collect(joining(" OR commit:", "commit:", ""));
+    assertThat(gApi.changes().query(q).get()).isEmpty();
+    assertThat(gApi.projects().name(project.get()).branch(master).get().revision)
+        .isEqualTo(Iterables.getLast(commits).name());
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
@@ -1839,11 +1867,21 @@
   }
 
   private List<RevCommit> createChanges(int n, String refsFor) throws Exception {
-    return createChanges(n, refsFor, ImmutableList.<String>of());
+    return createChanges(n, refsFor, ImmutableList.of());
   }
 
   private List<RevCommit> createChanges(int n, String refsFor, List<String> footerLines)
       throws Exception {
+    List<RevCommit> commits = initChanges(n, footerLines);
+    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
+    return commits;
+  }
+
+  private List<RevCommit> initChanges(int n) throws Exception {
+    return initChanges(n, ImmutableList.of());
+  }
+
+  private List<RevCommit> initChanges(int n, List<String> footerLines) throws Exception {
     List<RevCommit> commits = new ArrayList<>(n);
     for (int i = 1; i <= n; i++) {
       String msg = "Change " + i;
@@ -1863,7 +1901,6 @@
       testRepo.getRevWalk().parseBody(c);
       commits.add(c);
     }
-    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
     return commits;
   }
 
@@ -1934,4 +1971,15 @@
     config.getProject().setCreateNewChangeForAllNotInTarget(InheritableBoolean.TRUE);
     saveProjectConfig(project, config);
   }
+
+  private void grantSkipValidation(Project.NameKey project, String ref, AccountGroup.UUID groupUuid)
+      throws Exception {
+    // See SKIP_VALIDATION implementation in default permission backend.
+    ProjectConfig config = projectCache.checkedGet(project).getConfig();
+    Util.allow(config, Permission.FORGE_AUTHOR, groupUuid, ref);
+    Util.allow(config, Permission.FORGE_COMMITTER, groupUuid, ref);
+    Util.allow(config, Permission.FORGE_SERVER, groupUuid, ref);
+    Util.allow(config, Permission.PUSH_MERGE, groupUuid, "refs/for/" + ref);
+    saveProjectConfig(project, config);
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index b99e99d..b3a53b0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -834,7 +834,7 @@
     try (BatchUpdate bu = newUpdate(ctl.getChange().getOwner())) {
       ins =
           patchSetInserterFactory
-              .create(ctl, nextPatchSetId(ctl), commit)
+              .create(ctl.getNotes(), nextPatchSetId(ctl), commit)
               .setValidate(false)
               .setFireRevisionCreated(false)
               .setNotify(NotifyHandling.NONE);
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 f713ad2..53bf6a0 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
@@ -22,6 +22,7 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -257,6 +258,12 @@
 
   void index() throws RestApiException;
 
+  /** Check if this change is a pure revert of the change stored in revertOf. */
+  PureRevertInfo pureRevert() throws RestApiException;
+
+  /** Check if this change is a pure revert of claimedOriginal (SHA1 in 40 digit hex). */
+  PureRevertInfo pureRevert(String claimedOriginal) throws RestApiException;
+
   abstract class SuggestedReviewersRequest {
     private String query;
     private int limit;
@@ -548,5 +555,15 @@
     public void mute(boolean mute) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public PureRevertInfo pureRevert() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public PureRevertInfo pureRevert(String claimedOriginal) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java
new file mode 100644
index 0000000..7f0d7a8
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/PureRevertInfo.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class PureRevertInfo {
+  public boolean isPureRevert;
+
+  public PureRevertInfo() {}
+
+  public PureRevertInfo(boolean isPureRevert) {
+    this.isPureRevert = isPureRevert;
+  }
+}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
index 2629cec..b556519 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.java
@@ -31,6 +31,8 @@
 
   String defaultRevisionSpec();
 
+  String annotation();
+
   String buttonDeleteIncludedGroup();
 
   String buttonAddIncludedGroup();
@@ -183,6 +185,8 @@
 
   String columnTagRevision();
 
+  String columnTagAnnotation();
+
   String initialRevision();
 
   String revision();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
index 54f5c8b..5521da0 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/AdminConstants.properties
@@ -3,6 +3,7 @@
 defaultBranchName = Branch Name
 defaultTagName = Tag Name
 defaultRevisionSpec = Revision (Branch or SHA-1)
+annotation = Annotation (optional)
 
 buttonDeleteIncludedGroup = Delete
 buttonAddIncludedGroup = Add
@@ -87,6 +88,7 @@
 columnBranchRevision = Revision
 columnTagName = Tag Name
 columnTagRevision = Revision
+columnTagAnnotation = Annotation
 initialRevision = Initial Revision
 revision = Revision
 buttonAddBranch = Create Branch
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
index 3bad430..18e4176 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/admin/ProjectTagsScreen.java
@@ -71,6 +71,7 @@
   private Button addTag;
   private HintTextBox nameTxtBox;
   private HintTextBox irevTxtBox;
+  private HintTextBox annotationTxtBox;
   private FlowPanel addPanel;
   private NpTextBox filterTxt;
   private Query query;
@@ -105,6 +106,7 @@
     addTag.setEnabled(true);
     nameTxtBox.setEnabled(true);
     irevTxtBox.setEnabled(true);
+    annotationTxtBox.setEnabled(true);
   }
 
   @Override
@@ -120,14 +122,11 @@
 
     addPanel = new FlowPanel();
 
-    Grid addGrid = new Grid(2, 2);
+    Grid addGrid = new Grid(3, 2);
     addGrid.setStyleName(Gerrit.RESOURCES.css().addBranch());
     int texBoxLength = 50;
 
-    nameTxtBox = new HintTextBox();
-    nameTxtBox.setVisibleLength(texBoxLength);
-    nameTxtBox.setHintText(AdminConstants.I.defaultTagName());
-    nameTxtBox.addKeyPressHandler(
+    KeyPressHandler onKeyPress =
         new KeyPressHandler() {
           @Override
           public void onKeyPress(KeyPressEvent event) {
@@ -135,25 +134,29 @@
               doAddNewTag();
             }
           }
-        });
+        };
+
+    nameTxtBox = new HintTextBox();
+    nameTxtBox.setVisibleLength(texBoxLength);
+    nameTxtBox.setHintText(AdminConstants.I.defaultTagName());
+    nameTxtBox.addKeyPressHandler(onKeyPress);
     addGrid.setText(0, 0, AdminConstants.I.columnTagName() + ":");
     addGrid.setWidget(0, 1, nameTxtBox);
 
     irevTxtBox = new HintTextBox();
     irevTxtBox.setVisibleLength(texBoxLength);
     irevTxtBox.setHintText(AdminConstants.I.defaultRevisionSpec());
-    irevTxtBox.addKeyPressHandler(
-        new KeyPressHandler() {
-          @Override
-          public void onKeyPress(KeyPressEvent event) {
-            if (event.getNativeEvent().getKeyCode() == KeyCodes.KEY_ENTER) {
-              doAddNewTag();
-            }
-          }
-        });
+    irevTxtBox.addKeyPressHandler(onKeyPress);
     addGrid.setText(1, 0, AdminConstants.I.revision() + ":");
     addGrid.setWidget(1, 1, irevTxtBox);
 
+    annotationTxtBox = new HintTextBox();
+    annotationTxtBox.setVisibleLength(texBoxLength);
+    annotationTxtBox.setHintText(AdminConstants.I.annotation());
+    annotationTxtBox.addKeyPressHandler(onKeyPress);
+    addGrid.setText(2, 0, AdminConstants.I.columnTagAnnotation() + ":");
+    addGrid.setWidget(2, 1, annotationTxtBox);
+
     addTag = new Button(AdminConstants.I.buttonAddTag());
     addTag.addClickHandler(
         new ClickHandler() {
@@ -237,17 +240,24 @@
       return;
     }
 
+    String annotation = annotationTxtBox.getText().trim();
+    if (annotation.isEmpty()) {
+      annotation = null;
+    }
+
     addTag.setEnabled(false);
     ProjectApi.createTag(
         getProjectKey(),
         tagName,
         rev,
+        annotation,
         new GerritCallback<TagInfo>() {
           @Override
           public void onSuccess(TagInfo tag) {
             showAddedTag(tag);
             nameTxtBox.setText("");
             irevTxtBox.setText("");
+            annotationTxtBox.setText("");
             query = new Query(match).start(start).run();
           }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
index acee478..3766dd9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/projects/ProjectApi.java
@@ -62,9 +62,14 @@
 
   /** Create a new tag */
   public static void createTag(
-      Project.NameKey name, String ref, String revision, AsyncCallback<TagInfo> cb) {
+      Project.NameKey name,
+      String ref,
+      String revision,
+      String annotation,
+      AsyncCallback<TagInfo> cb) {
     TagInput input = TagInput.create();
     input.setRevision(revision);
+    input.setMessage(annotation);
     project(name).view("tags").id(ref).ifNoneMatch().put(input, cb);
   }
 
@@ -381,6 +386,8 @@
     protected TagInput() {}
 
     final native void setRevision(String r) /*-{ if(r)this.revision=r; }-*/;
+
+    final native void setMessage(String m) /*-{ if(m)this.message=m; }-*/;
   }
 
   private static class BranchInput extends JavaScriptObject {
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 558cc78..51c60af 100644
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -41,6 +41,10 @@
   // @see https://github.com/w3c/preload/issues/32 regarding crossorigin
   <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
   <link rel="preload" href="{$staticResourcePath}/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>{\n}
+  <link rel="preload" href="{$staticResourcePath}/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
   <script src="{$staticResourcePath}/bower_components/webcomponentsjs/webcomponents-lite.js"></script>{\n}
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index 4efbecc..072d1ed 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -619,7 +619,6 @@
 
     // If the build system provides us with a source root, use that.
     try (InputStream stream = self.getResourceAsStream(SOURCE_ROOT_RESOURCE)) {
-      System.err.println("URL: " + stream);
       if (stream != null) {
         try (Scanner scan = new Scanner(stream, UTF_8.name()).useDelimiter("\n")) {
           if (scan.hasNext()) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index 2068540..444f64f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -199,7 +199,7 @@
         T def, A options, String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       for (; ; ) {
-        String r = console.readLine("%-30s [%s/?]: ", prompt, def.toString());
+        String r = console.readLine("%-30s [%s/?]: ", prompt, def.toString().toLowerCase());
         if (r == null) {
           throw abort();
         }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index f1eef0b..89de9dc 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -102,19 +102,13 @@
   }
 
   public static String changeMetaRef(Change.Id id) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_CHANGES);
-    r.append(shard(id.get()));
-    r.append(META_SUFFIX);
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(META_SUFFIX).toString();
   }
 
   public static String robotCommentsRef(Change.Id id) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_CHANGES);
-    r.append(shard(id.get()));
-    r.append(ROBOT_COMMENTS_SUFFIX);
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(ROBOT_COMMENTS_SUFFIX).toString();
   }
 
   public static boolean isNoteDbMetaRef(String ref) {
@@ -129,16 +123,12 @@
   }
 
   public static String refsUsers(Account.Id accountId) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_USERS);
-    r.append(shard(accountId.get()));
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_USERS);
+    return shard(accountId.get(), r).toString();
   }
 
   public static String refsDraftComments(Change.Id changeId, Account.Id accountId) {
-    StringBuilder r = buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get());
-    r.append(accountId.get());
-    return r.toString();
+    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).append(accountId.get()).toString();
   }
 
   public static String refsDraftCommentsPrefix(Change.Id changeId) {
@@ -146,9 +136,7 @@
   }
 
   public static String refsStarredChanges(Change.Id changeId, Account.Id accountId) {
-    StringBuilder r = buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get());
-    r.append(accountId.get());
-    return r.toString();
+    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).append(accountId.get()).toString();
   }
 
   public static String refsStarredChangesPrefix(Change.Id changeId) {
@@ -156,11 +144,8 @@
   }
 
   private static StringBuilder buildRefsPrefix(String prefix, int id) {
-    StringBuilder r = new StringBuilder();
-    r.append(prefix);
-    r.append(shard(id));
-    r.append('/');
-    return r;
+    StringBuilder r = newStringBuilder().append(prefix);
+    return shard(id, r).append('/');
   }
 
   public static String refsCacheAutomerge(String hash) {
@@ -171,15 +156,18 @@
     if (id < 0) {
       return null;
     }
-    StringBuilder r = new StringBuilder();
+    return shard(id, newStringBuilder()).toString();
+  }
+
+  private static StringBuilder shard(int id, StringBuilder sb) {
     int n = id % 100;
     if (n < 10) {
-      r.append('0');
+      sb.append('0');
     }
-    r.append(n);
-    r.append('/');
-    r.append(id);
-    return r.toString();
+    sb.append(n);
+    sb.append('/');
+    sb.append(id);
+    return sb;
   }
 
   /**
@@ -363,5 +351,12 @@
     return Integer.valueOf(name.substring(i, name.length()));
   }
 
+  private static StringBuilder newStringBuilder() {
+    // Many refname types in this file are always are longer than the default of 16 chars, so
+    // presize StringBuilders larger by default. This hurts readability less than accurate
+    // calculations would, at a negligible cost to memory overhead.
+    return new StringBuilder(64);
+  }
+
   private RefNames() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index 6771616..538c7c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
@@ -183,7 +184,7 @@
 
         ChangeKind kind =
             changeKindCache.getChangeKind(
-                project.getProject().getNameKey(),
+                project.getNameKey(),
                 rw,
                 repoConfig,
                 ObjectId.fromString(priorPs.getRevision().get()),
@@ -204,7 +205,7 @@
         }
       }
       return labelNormalizer.normalize(ctl, byUser.values()).getNormalized();
-    } catch (IOException e) {
+    } catch (IOException | PermissionBackendException e) {
       throw new OrmException(e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 7750729..d858da5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -31,8 +31,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -48,6 +46,7 @@
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
@@ -308,7 +307,7 @@
    * @param update change update.
    * @param labelTypes label types for the containing project.
    * @param ps patch set being approved.
-   * @param changeCtl change control for user adding approvals.
+   * @param user user adding approvals.
    * @param approvals approvals to add.
    * @throws RestApiException
    * @throws OrmException
@@ -318,10 +317,10 @@
       ChangeUpdate update,
       LabelTypes labelTypes,
       PatchSet ps,
-      ChangeControl changeCtl,
+      CurrentUser user,
       Map<String, Short> approvals)
-      throws RestApiException, OrmException {
-    Account.Id accountId = changeCtl.getUser().getAccountId();
+      throws RestApiException, OrmException, PermissionBackendException {
+    Account.Id accountId = user.getAccountId();
     checkArgument(
         accountId.equals(ps.getUploader()),
         "expected user %s to match patch set uploader %s",
@@ -330,12 +329,12 @@
     if (approvals.isEmpty()) {
       return ImmutableList.of();
     }
-    checkApprovals(approvals, changeCtl);
+    checkApprovals(approvals, permissionBackend.user(user).database(db).change(update.getNotes()));
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.getId(), changeCtl.getUser(), lt.getLabelId(), vote.getValue(), ts));
+      cells.add(newApproval(ps.getId(), user, lt.getLabelId(), vote.getValue(), ts));
     }
     for (PatchSetApproval psa : cells) {
       update.putApproval(psa.getLabel(), psa.getValue());
@@ -356,13 +355,15 @@
     }
   }
 
-  private static void checkApprovals(Map<String, Short> approvals, ChangeControl changeCtl)
-      throws AuthException {
+  private static void checkApprovals(
+      Map<String, Short> approvals, PermissionBackend.ForChange forChange)
+      throws AuthException, PermissionBackendException {
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       String name = vote.getKey();
       Short value = vote.getValue();
-      PermissionRange range = changeCtl.getRange(Permission.forLabel(name));
-      if (range == null || !range.contains(value)) {
+      try {
+        forChange.check(new LabelPermission.WithValue(name, value));
+      } catch (AuthException e) {
         throw new AuthException(
             String.format("applying label \"%s\": %d is restricted", name, value));
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
index e9152d0..9bd7783 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
@@ -135,7 +135,7 @@
                   .getProvider()
                   .get()
                   .suggestReviewers(
-                      projectState.getProject().getNameKey(),
+                      projectState.getNameKey(),
                       changeNotes.getChangeId(),
                       query,
                       reviewerScores.keySet()));
@@ -239,8 +239,7 @@
     List<Predicate<ChangeData>> predicates = new ArrayList<>();
     for (Account.Id id : candidates) {
       try {
-        Predicate<ChangeData> projectQuery =
-            changeQueryBuilder.project(projectState.getProject().getName());
+        Predicate<ChangeData> projectQuery = changeQueryBuilder.project(projectState.getName());
 
         // Get all labels for this project and create a compound OR query to
         // fetch all changes where users have applied one of these labels
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index 3224601..cafdaed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -345,7 +345,8 @@
     ExternalId extId = externalIds.get(who.getExternalIdKey());
     if (extId != null) {
       if (!extId.accountId().equals(to)) {
-        throw new AccountException("Identity in use by another account");
+        throw new AccountException(
+            "Identity '" + extId.key().get() + "' in use by another account");
       }
       update(who, extId);
     } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index dfa0e7c..2dc9f6c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -59,6 +60,7 @@
 import com.google.gerrit.server.change.GetAssignee;
 import com.google.gerrit.server.change.GetHashtags;
 import com.google.gerrit.server.change.GetPastAssignees;
+import com.google.gerrit.server.change.GetPureRevert;
 import com.google.gerrit.server.change.GetTopic;
 import com.google.gerrit.server.change.Ignore;
 import com.google.gerrit.server.change.Index;
@@ -142,6 +144,7 @@
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
+  private final GetPureRevert getPureRevert;
 
   @Inject
   ChangeApiImpl(
@@ -186,6 +189,7 @@
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
+      GetPureRevert getPureRevert,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -228,6 +232,7 @@
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
+    this.getPureRevert = getPureRevert;
     this.change = change;
   }
 
@@ -669,4 +674,18 @@
       unmute.apply(change, new Unmute.Input());
     }
   }
+
+  @Override
+  public PureRevertInfo pureRevert() throws RestApiException {
+    return pureRevert(null);
+  }
+
+  @Override
+  public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
+    try {
+      return getPureRevert.setClaimedOriginal(claimedOriginal).apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot compute pure revert", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 385baba..e3e3e32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -378,7 +378,7 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException {
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
     change = ctx.getChange(); // Use defensive copy created by ChangeControl.
     ReviewDb db = ctx.getDb();
     ChangeControl ctl = ctx.getControl();
@@ -444,7 +444,7 @@
         filterOnChangeVisibility(db, ctx.getNotes(), reviewersToAdd),
         Collections.<Account.Id>emptySet());
     approvalsUtil.addApprovalsForNewPatchSet(
-        db, update, labelTypes, patchSet, ctx.getControl(), approvals);
+        db, update, labelTypes, patchSet, ctx.getUser(), approvals);
     // Check if approvals are changing in with this update. If so, add current user to reviewers.
     // Note that this is done separately as addReviewers is filtering out the change owner as
     // reviewer which is needed in several other code paths.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index 6e555e5..12c0483 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -246,8 +245,7 @@
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
-            ChangeControl destCtl = projectControl.controlFor(destChanges.get(0).notes());
-            result = insertPatchSet(bu, git, destCtl, cherryPickCommit, input);
+            result = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit, input);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
@@ -321,15 +319,15 @@
   private Change.Id insertPatchSet(
       BatchUpdate bu,
       Repository git,
-      ChangeControl destCtl,
+      ChangeNotes destNotes,
       CodeReviewCommit cherryPickCommit,
       CherryPickInput input)
       throws IOException, OrmException, BadRequestException, ConfigInvalidException {
-    Change destChange = destCtl.getChange();
+    Change destChange = destNotes.getChange();
     PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
-    PatchSet current = psUtil.current(dbProvider.get(), destCtl.getNotes());
+    PatchSet current = psUtil.current(dbProvider.get(), destNotes);
 
-    PatchSetInserter inserter = patchSetInserterFactory.create(destCtl, psId, cherryPickCommit);
+    PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
     inserter
         .setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".")
         .setDraft(current.isDraft())
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
index a3927b5..0444e0a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
@@ -76,7 +76,7 @@
     input.message = message.isEmpty() ? commit.getFullMessage() : message;
     String destination = Strings.nullToEmpty(input.destination).trim();
     input.parent = input.parent == null ? 1 : input.parent;
-    Project.NameKey projectName = rsrc.getProjectState().getProject().getNameKey();
+    Project.NameKey projectName = rsrc.getProjectState().getNameKey();
 
     if (destination.isEmpty()) {
       throw new BadRequestException("destination must be non-empty");
@@ -99,7 +99,7 @@
               projectName,
               commit,
               input,
-              new Branch.NameKey(rsrc.getProjectState().getProject().getNameKey(), refName));
+              new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
       return json.noOptions().format(projectName, cherryPickedChangeId);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 46d8063..76d5550 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -515,7 +515,7 @@
           (psIdToDelete != null && reuseOldPsId)
               ? psIdToDelete
               : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
-      PatchSetInserter inserter = patchSetInserterFactory.create(ctl, psId, commit);
+      PatchSetInserter inserter = patchSetInserterFactory.create(ctl.getNotes(), psId, commit);
       try (BatchUpdate bu = newBatchUpdate()) {
         bu.setRepository(repo, rw, oi);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
index b02f31b..072052b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
@@ -154,7 +154,7 @@
 
       PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
       PatchSetInserter psInserter =
-          patchSetInserterFactory.create(rsrc.getControl(), nextPsId, newCommit);
+          patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
       try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
         bu.setRepository(git, rw, oi);
         bu.addOp(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
index ee93da0..c725089 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -127,7 +127,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            db.get(), change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
+            db.get(), change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
       bu.addOp(
           change.getId(),
           new Op(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
index 181505d..ff5fb0b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -315,6 +315,6 @@
 
   private Repository openRepository(ProjectState project)
       throws RepositoryNotFoundException, IOException {
-    return repoManager.openRepository(project.getProject().getNameKey());
+    return repoManager.openRepository(project.getNameKey());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
index 1ac5a88f..eec318b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
@@ -218,7 +218,7 @@
 
       List<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
-              state.getProject().getName(),
+              state.getName(),
               resource.getPatchKey().getParentKey().getParentKey().get(),
               basePatchSet != null ? basePatchSet.getId().get() : null,
               revA,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPureRevert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPureRevert.java
new file mode 100644
index 0000000..c849134
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPureRevert.java
@@ -0,0 +1,147 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetPureRevert implements RestReadView<ChangeResource> {
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+  private final ChangeNotes.Factory notesFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final PatchSetUtil psUtil;
+
+  @Option(
+    name = "--claimed-original",
+    aliases = {"-o"},
+    usage = "SHA1 (40 digit hex) of the original commit"
+  )
+  @Nullable
+  private String claimedOriginal;
+
+  @Inject
+  GetPureRevert(
+      MergeUtil.Factory mergeUtilFactory,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider,
+      PatchSetUtil psUtil) {
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.repoManager = repoManager;
+    this.projectCache = projectCache;
+    this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
+    this.psUtil = psUtil;
+  }
+
+  @Override
+  public PureRevertInfo apply(ChangeResource rsrc)
+      throws ResourceConflictException, IOException, BadRequestException, OrmException,
+          AuthException {
+    PatchSet currentPatchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
+    if (currentPatchSet == null) {
+      throw new ResourceConflictException("current revision is missing");
+    } else if (!rsrc.getControl().isPatchVisible(currentPatchSet, dbProvider.get())) {
+      throw new AuthException("current revision not accessible");
+    }
+
+    if (claimedOriginal == null) {
+      if (rsrc.getChange().getRevertOf() == null) {
+        throw new BadRequestException("no ID was provided and change isn't a revert");
+      }
+      PatchSet ps =
+          psUtil.current(
+              dbProvider.get(),
+              notesFactory.createChecked(
+                  dbProvider.get(), rsrc.getProject(), rsrc.getChange().getRevertOf()));
+      claimedOriginal = ps.getRevision().get();
+    }
+
+    try (Repository repo = repoManager.openRepository(rsrc.getProject());
+        ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit claimedOriginalCommit;
+      try {
+        claimedOriginalCommit = rw.parseCommit(ObjectId.fromString(claimedOriginal));
+      } catch (InvalidObjectIdException | MissingObjectException e) {
+        throw new BadRequestException("invalid object ID");
+      }
+      if (claimedOriginalCommit.getParentCount() == 0) {
+        throw new BadRequestException("can't check against initial commit");
+      }
+      RevCommit claimedRevertCommit =
+          rw.parseCommit(ObjectId.fromString(currentPatchSet.getRevision().get()));
+      if (claimedRevertCommit.getParentCount() == 0) {
+        throw new BadRequestException("claimed revert has no parents");
+      }
+      // Rebase claimed revert onto claimed original
+      ThreeWayMerger merger =
+          mergeUtilFactory
+              .create(projectCache.checkedGet(rsrc.getProject()))
+              .newThreeWayMerger(oi, repo.getConfig());
+      merger.setBase(claimedRevertCommit.getParent(0));
+      merger.merge(claimedRevertCommit, claimedOriginalCommit);
+      if (merger.getResultTreeId() == null) {
+        // Merge conflict during rebase
+        return new PureRevertInfo(false);
+      }
+
+      // Any differences between claimed original's parent and the rebase result indicate that the
+      // claimedRevert is not a pure revert but made content changes
+      try (DiffFormatter df = new DiffFormatter(new ByteArrayOutputStream())) {
+        df.setRepository(repo);
+        List<DiffEntry> entries =
+            df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
+        return new PureRevertInfo(entries.isEmpty());
+      }
+    }
+  }
+
+  public GetPureRevert setClaimedOriginal(String claimedOriginal) {
+    this.claimedOriginal = claimedOriginal;
+    return this;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index bf76af9..5089574 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -70,6 +70,7 @@
     get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
     get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
     get(CHANGE_KIND, "check").to(Check.class);
+    get(CHANGE_KIND, "pure_revert").to(GetPureRevert.class);
     post(CHANGE_KIND, "check").to(Check.class);
     put(CHANGE_KIND, "topic").to(PutTopic.class);
     delete(CHANGE_KIND, "topic").to(PutTopic.class);
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 5e26305..77f4e5c 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
@@ -27,7 +27,6 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -44,12 +43,14 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -70,7 +71,7 @@
   private static final Logger log = LoggerFactory.getLogger(PatchSetInserter.class);
 
   public interface Factory {
-    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId, ObjectId commitId);
+    PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
   }
 
   // Injected fields.
@@ -78,6 +79,7 @@
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ApprovalCopier approvalCopier;
@@ -88,9 +90,9 @@
   private final PatchSet.Id psId;
   private final ObjectId commitId;
   // Read prior to running the batch update, so must only be used during
-  // updateRepo; updateChange and later must use the control from the
+  // updateRepo; updateChange and later must use the notes from the
   // ChangeContext.
-  private final ChangeControl origCtl;
+  private final ChangeNotes origNotes;
 
   // Fields exposed as setters.
   private String message;
@@ -123,7 +125,8 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
-      @Assisted ChangeControl ctl,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
       @Assisted ObjectId commitId) {
     this.permissionBackend = permissionBackend;
@@ -135,8 +138,9 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
+    this.projectCache = projectCache;
 
-    this.origCtl = ctl;
+    this.origNotes = notes;
     this.psId = psId;
     this.commitId = commitId.copy();
   }
@@ -316,7 +320,7 @@
       permissionBackend
           .user(ctx.getUser())
           .database(ctx.getDb())
-          .change(origCtl.getNotes())
+          .change(origNotes)
           .check(ChangePermission.ADD_PATCH_SET);
     }
     if (!validate) {
@@ -324,7 +328,7 @@
     }
 
     PermissionBackend.ForRef perm =
-        permissionBackend.user(ctx.getUser()).ref(origCtl.getChange().getDest());
+        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
 
     String refName = getPatchSetId().toRefName();
     try (CommitReceivedEvent event =
@@ -333,16 +337,15 @@
                 ObjectId.zeroId(),
                 commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
-            origCtl.getProjectControl().getProject(),
-            origCtl.getRefControl().getRefName(),
+            projectCache.checkedGet(origNotes.getProjectName()).getProject(),
+            origNotes.getChange().getDest().get(),
             ctx.getRevWalk().getObjectReader(),
             commitId,
             ctx.getIdentifiedUser())) {
       commitValidatorsFactory
           .forGerritCommits(
               perm,
-              new Branch.NameKey(
-                  origCtl.getProject().getNameKey(), origCtl.getRefControl().getRefName()),
+              origNotes.getChange().getDest(),
               ctx.getIdentifiedUser(),
               new NoSshInfo(),
               ctx.getRevWalk())
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java
index 7d17dfb..a1a5ab7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java
@@ -141,8 +141,7 @@
         PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.getId());
         ObjectId newCommit =
             createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
-        PatchSetInserter inserter =
-            psInserterFactory.create(resource.getControl(), psId, newCommit);
+        PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
         inserter.setMessage(
             String.format("Patch Set %s: Commit message was updated.", psId.getId()));
         inserter.setDescription("Edit commit message");
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 34d239c..465a1b3 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
@@ -172,7 +172,7 @@
             ctl.getChange().currentPatchSetId());
     patchSetInserter =
         patchSetInserterFactory
-            .create(ctl, rebasedPatchSetId, rebasedCommit)
+            .create(ctl.getNotes(), rebasedPatchSetId, rebasedCommit)
             .setDescription("Rebase")
             .setDraft(originalPatchSet.isDraft())
             .setNotify(NotifyHandling.NONE)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index be31c99..310f700 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -20,7 +20,6 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.reviewdb.client.Account;
@@ -84,7 +83,7 @@
     ChangeData cd = null;
     for (ReviewerResource rsrc : rsrcs) {
       if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
-        cd = changeDataFactory.create(db.get(), rsrc.getControl().getNotes());
+        cd = changeDataFactory.create(db.get(), rsrc.getChangeResource().getNotes());
       }
       ReviewerInfo info =
           format(
@@ -125,13 +124,9 @@
 
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      for (PermissionRange pr : cd.changeControl().getLabelRanges()) {
-        if (!pr.isEmpty()) {
-          LabelType at = labelTypes.byLabel(ca.getLabelId());
-          if (at != null) {
-            out.approvals.put(at.getName(), formatValue(ca.getValue()));
-          }
-        }
+      LabelType at = labelTypes.byLabel(ca.getLabelId());
+      if (at != null) {
+        out.approvals.put(at.getName(), formatValue(ca.getValue()));
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index f6f7919..47e25b04 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.Address;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -114,25 +113,4 @@
   public boolean isByEmail() {
     return user == null;
   }
-
-  /**
-   * Get the control for the caller's user.
-   *
-   * @return the control for the caller's user (as opposed to the reviewer's user as returned by
-   *     {@link #getReviewerControl()}).
-   */
-  public ChangeControl getControl() {
-    return change.getControl();
-  }
-
-  /**
-   * Get the control for the reviewer's user.
-   *
-   * @return the control for the reviewer's user (as opposed to the caller's user as returned by
-   *     {@link #getControl()}).
-   */
-  public ChangeControl getReviewerControl() {
-    checkArgument(user != null, "no user provided");
-    return change.getControl().forUser(user);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
index ddf48fd..980e6e5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
@@ -84,7 +84,7 @@
       Iterable<PatchSetApproval> byPatchSetUser =
           approvalsUtil.byPatchSetUser(
               db.get(),
-              rsrc.getControl(),
+              rsrc.getChangeResource().getControl(),
               rsrc.getChange().currentPatchSetId(),
               rsrc.getReviewerUser().getAccountId(),
               null,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 743df20..917c005 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -180,7 +180,7 @@
       PatchSet.Id psId = ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
       PatchSetInserter inserter =
           patchSetInserterFactory
-              .create(ctl, psId, squashed)
+              .create(ctl.getNotes(), psId, squashed)
               .setNotify(notify)
               .setAccountsToNotify(accountsToNotify);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
index a3f5093..2eeed24 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -24,13 +24,15 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -76,15 +78,18 @@
   private final Provider<ReviewDb> db;
   private final ChangeControl.GenericFactory changeFactory;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   LabelNormalizer(
       Provider<ReviewDb> db,
       ChangeControl.GenericFactory changeFactory,
-      IdentifiedUser.GenericFactory userFactory) {
+      IdentifiedUser.GenericFactory userFactory,
+      PermissionBackend permissionBackend) {
     this.db = db;
     this.changeFactory = changeFactory;
     this.userFactory = userFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   /**
@@ -96,7 +101,7 @@
    * @throws OrmException
    */
   public Result normalize(Change change, Collection<PatchSetApproval> approvals)
-      throws OrmException {
+      throws OrmException, PermissionBackendException {
     IdentifiedUser user = userFactory.create(change.getOwner());
     return normalize(changeFactory.controlFor(db.get(), change, user), approvals);
   }
@@ -108,7 +113,8 @@
    *     for the user. Approvals for unknown labels are not included in the output, nor are
    *     approvals where the user has no permissions for that label.
    */
-  public Result normalize(ChangeControl ctl, Collection<PatchSetApproval> approvals) {
+  public Result normalize(ChangeControl ctl, Collection<PatchSetApproval> approvals)
+      throws PermissionBackendException {
     List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
@@ -132,7 +138,7 @@
       }
       PatchSetApproval copy = copy(psa);
       applyTypeFloor(label, copy);
-      if (!applyRightFloor(ctl, label, copy)) {
+      if (!applyRightFloor(ctl.getNotes(), label, copy)) {
         deleted.add(psa);
       } else if (copy.getValue() != psa.getValue()) {
         updated.add(copy);
@@ -147,19 +153,24 @@
     return new PatchSetApproval(src.getPatchSetId(), src);
   }
 
-  private PermissionRange getRange(ChangeControl ctl, LabelType lt, Account.Id id) {
-    String permission = Permission.forLabel(lt.getName());
-    IdentifiedUser user = userFactory.create(id);
-    return ctl.forUser(user).getRange(permission);
-  }
-
-  private boolean applyRightFloor(ChangeControl ctl, LabelType lt, PatchSetApproval a) {
-    PermissionRange range = getRange(ctl, lt, a.getAccountId());
-    if (range.isEmpty()) {
+  private boolean applyRightFloor(ChangeNotes notes, LabelType lt, PatchSetApproval a)
+      throws PermissionBackendException {
+    PermissionBackend.ForChange forChange =
+        permissionBackend.user(userFactory.create(a.getAccountId())).database(db).change(notes);
+    // Check if the user is allowed to vote on the label at all
+    try {
+      forChange.check(new LabelPermission(lt.getName()));
+    } catch (AuthException e) {
       return false;
     }
-    a.setValue((short) range.squash(a.getValue()));
-    return true;
+    // Squash vote to nearest allowed value
+    try {
+      forChange.check(new LabelPermission.WithValue(lt.getName(), a.getValue()));
+      return true;
+    } catch (AuthException e) {
+      a.setValue(forChange.squashThenCheck(lt, a.getValue()));
+      return true;
+    }
   }
 
   private void applyTypeFloor(LabelType lt, PatchSetApproval a) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
index e7303e8..29b2548 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
@@ -95,7 +95,7 @@
     }
 
     Project.NameKey getProjectName() {
-      return project.getProject().getNameKey();
+      return project.getNameKey();
     }
 
     public CodeReviewRevWalk getCodeReviewRevWalk() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index 07ea774..9d7dd19 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -77,6 +77,7 @@
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final PermissionBackend permissionBackend;
+  private final PermissionBackend.ForProject perm;
   private final ProjectState projectState;
   private final Repository git;
   private ProjectControl projectCtl;
@@ -100,6 +101,8 @@
     this.db = db;
     this.user = user;
     this.permissionBackend = permissionBackend;
+    this.perm =
+        permissionBackend.user(user).database(db).project(projectState.getProject().getNameKey());
     this.projectState = projectState;
     this.git = git;
   }
@@ -185,7 +188,7 @@
     if (!deferredTags.isEmpty() && (!result.isEmpty() || filterTagsSeparately)) {
       TagMatcher tags =
           tagCache
-              .get(projectState.getProject().getNameKey())
+              .get(projectState.getNameKey())
               .matcher(
                   tagCache,
                   git,
@@ -265,31 +268,25 @@
   }
 
   private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
-    Project project = projectCtl.getProject();
+    Project.NameKey project = projectState.getNameKey();
     try {
       Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
-      for (ChangeData cd : changeCache.getChangeData(db.get(), project.getNameKey())) {
-        if (permissionBackend
-            .user(user)
-            .indexedChange(cd, changeNotesFactory.createFromIndexedChange(cd.change()))
-            .database(db)
-            .test(ChangePermission.READ)) {
+      for (ChangeData cd : changeCache.getChangeData(db.get(), project)) {
+        ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
+        if (perm.indexedChange(cd, notes).test(ChangePermission.READ)) {
           visibleChanges.put(cd.getId(), cd.change().getDest());
         }
       }
       return visibleChanges;
     } catch (OrmException | PermissionBackendException e) {
       log.error(
-          "Cannot load changes for project "
-              + project.getName()
-              + ", assuming no changes are visible",
-          e);
+          "Cannot load changes for project " + project + ", assuming no changes are visible", e);
       return Collections.emptyMap();
     }
   }
 
   private Map<Change.Id, Branch.NameKey> visibleChangesByScan() {
-    Project.NameKey p = projectCtl.getProject().getNameKey();
+    Project.NameKey p = projectState.getNameKey();
     Stream<ChangeNotesResult> s;
     try {
       s = changeNotesFactory.scan(git, db.get(), p);
@@ -309,7 +306,7 @@
       return null;
     }
     try {
-      if (permissionBackend.user(user).change(r.notes()).database(db).test(ChangePermission.READ)) {
+      if (perm.change(r.notes()).test(ChangePermission.READ)) {
         return r.notes();
       }
     } catch (PermissionBackendException e) {
@@ -330,17 +327,13 @@
 
   private boolean canReadRef(String ref) {
     try {
-      permissionBackend
-          .user(user)
-          .project(projectCtl.getProject().getNameKey())
-          .ref(ref)
-          .check(RefPermission.READ);
+      perm.ref(ref).check(RefPermission.READ);
+      return true;
     } catch (AuthException e) {
       return false;
     } catch (PermissionBackendException e) {
       log.error("unable to check permissions", e);
       return false;
     }
-    return true;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 80c896f..042dad2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.COMMAND_REJECTION_MESSAGE_FOOTER;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.ONLY_OWNER_CAN_MODIFY_WIP;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
 import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_ID_IN_MULTIPLE_CHANGES;
 import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
@@ -198,7 +199,6 @@
 /** Receives change upload using the Git receive-pack protocol. */
 class ReceiveCommits {
   private static final Logger log = LoggerFactory.getLogger(ReceiveCommits.class);
-  private static final String BYPASS_REVIEW = "bypass-review";
 
   private enum ReceiveError {
     CONFIG_UPDATE(
@@ -362,6 +362,7 @@
   // Other settings populated during processing.
   private MagicBranchInput magicBranch;
   private boolean newChangeForAllNotInTarget;
+  private String setFullNameTo;
 
   // Handles for outputting back over the wire to the end user.
   private Task newProgress;
@@ -587,6 +588,9 @@
       }
     }
 
+    // Update account info with details discovered during commit walking.
+    updateAccountInfo();
+
     closeProgress.end();
     commandProgress.end();
     progress.end();
@@ -1000,9 +1004,10 @@
     }
 
     Branch.NameKey branch = new Branch.NameKey(project.getName(), cmd.getRefName());
-    String rejectReason = createRefControl.canCreateRef(rp.getRepository(), obj, user, branch);
-    if (rejectReason != null) {
-      reject(cmd, "prohibited by Gerrit: " + rejectReason);
+    try {
+      createRefControl.checkCreateRef(rp.getRepository(), branch, obj);
+    } catch (AuthException denied) {
+      reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
       return;
     }
 
@@ -2681,11 +2686,11 @@
     if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
         && !(MagicBranch.isMagicBranch(cmd.getRefName())
             || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
-        && pushOptions.containsKey(BYPASS_REVIEW)) {
+        && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION)) {
       try {
-        perm.check(RefPermission.BYPASS_REVIEW);
+        perm.check(RefPermission.SKIP_VALIDATION);
         if (!Iterables.isEmpty(rejectCommits)) {
-          throw new AuthException("reject-commits prevents " + BYPASS_REVIEW);
+          throw new AuthException("reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
         }
         logDebug("Short-circuiting new commit validation");
       } catch (AuthException denied) {
@@ -2694,7 +2699,7 @@
       return;
     }
 
-    boolean defaultName = Strings.isNullOrEmpty(user.getAccount().getFullName());
+    boolean missingFullName = Strings.isNullOrEmpty(user.getAccount().getFullName());
     RevWalk walk = rp.getRevWalk();
     walk.reset();
     walk.sort(RevSort.NONE);
@@ -2706,39 +2711,35 @@
       ListMultimap<ObjectId, Ref> existing = changeRefsById();
       walk.markStart((RevCommit) parsedObject);
       markHeadsAsUninteresting(walk, cmd.getRefName());
-      int i = 0;
+      int limit = receiveConfig.maxBatchCommits;
+      int n = 0;
       for (RevCommit c; (c = walk.next()) != null; ) {
-        i++;
+        if (++n > limit) {
+          logDebug("Number of new commits exceeds limit of {}", limit);
+          addMessage(
+              "Cannot push more than "
+                  + limit
+                  + " commits to "
+                  + branch.get()
+                  + " without "
+                  + PUSH_OPTION_SKIP_VALIDATION
+                  + " option");
+          reject(cmd, "too many commits");
+          return;
+        }
         if (existing.keySet().contains(c)) {
           continue;
         } else if (!validCommit(walk, perm, branch, cmd, c)) {
           break;
         }
 
-        if (defaultName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
-          try {
-            String committerName = c.getCommitterIdent().getName();
-            Account account =
-                accountsUpdate
-                    .create()
-                    .update(
-                        user.getAccountId(),
-                        a -> {
-                          if (Strings.isNullOrEmpty(a.getFullName())) {
-                            a.setFullName(committerName);
-                          }
-                        });
-            if (account != null && Strings.isNullOrEmpty(account.getFullName())) {
-              user.getAccount().setFullName(account.getFullName());
-            }
-          } catch (IOException | ConfigInvalidException e) {
-            logWarn("Cannot default full_name", e);
-          } finally {
-            defaultName = false;
-          }
+        if (missingFullName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
+          logDebug("Will update full name of caller");
+          setFullNameTo = c.getCommitterIdent().getName();
+          missingFullName = false;
         }
       }
-      logDebug("Validated {} new commits", i);
+      logDebug("Validated {} new commits", n);
     } catch (IOException err) {
       cmd.setResult(REJECTED_MISSING_OBJECT);
       logError("Invalid pack upload; one or more objects weren't sent", err);
@@ -2880,6 +2881,30 @@
     }
   }
 
+  private void updateAccountInfo() {
+    if (setFullNameTo == null) {
+      return;
+    }
+    logDebug("Updating full name of caller");
+    try {
+      Account account =
+          accountsUpdate
+              .create()
+              .update(
+                  user.getAccountId(),
+                  a -> {
+                    if (Strings.isNullOrEmpty(a.getFullName())) {
+                      a.setFullName(setFullNameTo);
+                    }
+                  });
+      if (account != null) {
+        user.getAccount().setFullName(account.getFullName());
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      logWarn("Failed to update full name of caller", e);
+    }
+  }
+
   private Map<Change.Key, ChangeNotes> openChangesByBranch(Branch.NameKey branch)
       throws OrmException {
     Map<Change.Key, ChangeNotes> r = new HashMap<>();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
index 7be6dcc..30d5071 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
@@ -28,6 +28,7 @@
   final boolean checkMagicRefs;
   final boolean checkReferencedObjectsAreReachable;
   final boolean allowDrafts;
+  final int maxBatchCommits;
   private final int systemMaxBatchChanges;
   private final AccountLimits.Factory limitsFactory;
 
@@ -37,6 +38,7 @@
     checkReferencedObjectsAreReachable =
         config.getBoolean("receive", null, "checkReferencedObjectsAreReachable", true);
     allowDrafts = config.getBoolean("change", null, "allowDrafts", true);
+    maxBatchCommits = config.getInt("receive", null, "maxBatchCommits", 10000);
     systemMaxBatchChanges = config.getInt("receive", "maxBatchChanges", 0);
     this.limitsFactory = limitsFactory;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
index 99742f3..92723e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
@@ -17,6 +17,8 @@
 import com.google.common.annotations.VisibleForTesting;
 
 public final class ReceiveConstants {
+  public static final String PUSH_OPTION_SKIP_VALIDATION = "skip-validation";
+
   @VisibleForTesting
   public static final String ONLY_OWNER_CAN_MODIFY_WIP =
       "only change owner can modify Work-in-Progress";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 0187304..3399071 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -52,6 +52,7 @@
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -224,7 +225,7 @@
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException {
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
     notes = ctx.getNotes();
     Change change = notes.getChange();
     if (change == null || change.getStatus().isClosed()) {
@@ -306,7 +307,7 @@
             update,
             projectControl.getProjectState().getLabelTypes(),
             newPatchSet,
-            ctx.getControl(),
+            ctx.getUser(),
             approvals);
     approvalCopier.copyInReviewDb(
         ctx.getDb(),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
index a3163c3..affa918 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.SubmoduleException;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -330,7 +331,7 @@
   }
 
   private void setApproval(ChangeContext ctx, IdentifiedUser user)
-      throws OrmException, IOException {
+      throws OrmException, IOException, PermissionBackendException {
     Change.Id id = ctx.getChange().getId();
     List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
@@ -352,7 +353,7 @@
   }
 
   private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws OrmException, IOException {
+      throws OrmException, IOException, PermissionBackendException {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
     for (PatchSetApproval psa :
@@ -525,7 +526,7 @@
         try (Repository git = args.repoManager.openRepository(getProject())) {
           git.setGitwebDescription(p.getProject().getDescription());
         } catch (IOException e) {
-          log.error("cannot update description of " + p.getProject().getName(), e);
+          log.error("cannot update description of " + p.getName(), e);
         }
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index b384405..f4deee8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -391,7 +391,7 @@
                   + " tried to push an invalid project configuration "
                   + receiveEvent.command.getNewId().name()
                   + " for project "
-                  + receiveEvent.project.getName(),
+                  + receiveEvent.project,
               e);
           throw new CommitValidationException("invalid project configuration", messages);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
index fd524b4..51e47a0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -145,7 +145,7 @@
       if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
         final Project.NameKey newParent;
         try {
-          ProjectConfig cfg = new ProjectConfig(destProject.getProject().getNameKey());
+          ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
           cfg.load(repo, commit);
           newParent = cfg.getProject().getParent(allProjectsName);
           final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
@@ -256,7 +256,7 @@
         IdentifiedUser caller)
         throws MergeValidationException {
       Account.Id accountId = Account.Id.fromRef(destBranch.get());
-      if (!allUsersName.equals(destProject.getProject().getNameKey()) || accountId == null) {
+      if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
         return;
       }
 
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 7d83c5c..2d1fc56 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
@@ -258,9 +258,7 @@
             log.warn(
                 String.format(
                     "Cannot load %s from %s in %s",
-                    c.key.filename,
-                    patchList.getNewId().name(),
-                    projectState.getProject().getName()),
+                    c.key.filename, patchList.getNewId().name(), projectState.getName()),
                 e);
             currentGroup.fileData = null;
           }
@@ -586,7 +584,7 @@
 
   private Repository getRepository() {
     try {
-      return args.server.openRepository(projectState.getProject().getNameKey());
+      return args.server.openRepository(projectState.getNameKey());
     } catch (IOException e) {
       return null;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index d1434bc..e1b6e36 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -103,7 +103,7 @@
           } catch (QueryParseException e) {
             log.warn(
                 "Project {} has invalid notify {} filter \"{}\": {}",
-                state.getProject().getName(),
+                state.getName(),
                 nc.getName(),
                 nc.getFilter(),
                 e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 5c0cf44..6db9357 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -268,6 +268,15 @@
       return ref(notes.getChange().getDest().get()).change(notes);
     }
 
+    /**
+     * @return instance scoped for the change loaded from index, and its destination ref and
+     *     project. This method should only be used when database access is harmful and potentially
+     *     stale data from the index is acceptable.
+     */
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return ref(notes.getChange().getDest().get()).indexedChange(cd, notes);
+    }
+
     /** Verify scoped user can {@code perm}, throwing if denied. */
     public abstract void check(ProjectPermission perm)
         throws AuthException, PermissionBackendException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
index e03272b..8b5d8fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -29,7 +29,7 @@
   FORGE_COMMITTER(Permission.FORGE_COMMITTER),
   FORGE_SERVER(Permission.FORGE_SERVER),
   MERGE,
-  BYPASS_REVIEW,
+  SKIP_VALIDATION,
 
   /** Create a change to code review a commit. */
   CREATE_CHANGE,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index b9649ed..f5e4542 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -48,7 +48,6 @@
 import java.io.IOException;
 import java.util.Collection;
 import java.util.EnumSet;
-import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -269,13 +268,8 @@
     return canAbandon(db) && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
-  /** All value ranges of any allowed label permission. */
-  public List<PermissionRange> getLabelRanges() {
-    return getRefControl().getLabelRanges(isOwner());
-  }
-
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission) {
+  private PermissionRange getRange(String permission) {
     return getRefControl().getRange(permission, isOwner());
   }
 
@@ -315,7 +309,7 @@
   }
 
   /** Is this user the owner of the change? */
-  boolean isOwner() {
+  private boolean isOwner() {
     if (getUser().isIdentifiedUser()) {
       Account.Id id = getUser().asIdentifiedUser().getAccountId();
       return id.equals(getChange().getOwner());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
index edd3fb6..b372b38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
@@ -41,6 +41,6 @@
 
   public boolean isDirectChild() {
     ProjectState firstParent = Iterables.getFirst(child.parents(), null);
-    return firstParent != null && parent.getNameKey().equals(firstParent.getProject().getNameKey());
+    return firstParent != null && parent.getNameKey().equals(firstParent.getNameKey());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
index 3152e97..5b36916 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
@@ -38,7 +38,7 @@
   public IncludedInInfo apply(CommitResource rsrc)
       throws RestApiException, OrmException, IOException {
     RevCommit commit = rsrc.getCommit();
-    Project.NameKey project = rsrc.getProjectState().getProject().getNameKey();
+    Project.NameKey project = rsrc.getProjectState().getNameKey();
     return includedIn.apply(project, commit.getId().getName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
index e38f442..a504a1f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
@@ -101,7 +101,7 @@
 
   /** @return true if {@code commit} is visible to the caller. */
   public boolean canRead(ProjectState state, Repository repo, RevCommit commit) {
-    Project.NameKey project = state.getProject().getNameKey();
+    Project.NameKey project = state.getNameKey();
 
     // Look for changes associated with the commit.
     try {
@@ -126,7 +126,7 @@
       log.error(
           String.format(
               "Cannot verify permissions to commit object %s in repository %s",
-              commit.name(), state.getProject().getNameKey()),
+              commit.name(), state.getNameKey()),
           e);
       return false;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
index 34c3287..eb0dde4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -159,7 +159,7 @@
       p.type = configEntry.getType();
       p.permittedValues = configEntry.getPermittedValues();
       p.editable = configEntry.isEditable(project) ? true : null;
-      if (configEntry.isInheritable() && !allProjects.equals(project.getProject().getNameKey())) {
+      if (configEntry.isInheritable() && !allProjects.equals(project.getNameKey())) {
         PluginConfig cfgWithInheritance =
             cfgFactory.getFromProjectConfigWithInheritance(project, e.getPluginName());
         p.inheritable = true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
index 77fb86b..f15571c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
@@ -121,10 +121,7 @@
         }
       }
 
-      String rejectReason = createRefControl.canCreateRef(repo, object, identifiedUser.get(), name);
-      if (rejectReason != null) {
-        throw new AuthException("Cannot create \"" + ref + "\": " + rejectReason);
-      }
+      createRefControl.checkCreateRef(repo, name, object);
 
       try {
         final RefUpdate u = repo.updateRef(ref);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java
index aa48a73..2034cc4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.inject.Provider;
 import java.io.IOException;
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -41,137 +41,103 @@
 
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final Provider<CurrentUser> user;
 
   @Inject
-  CreateRefControl(PermissionBackend permissionBackend, ProjectCache projectCache) {
+  CreateRefControl(
+      PermissionBackend permissionBackend, ProjectCache projectCache, Provider<CurrentUser> user) {
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.user = user;
   }
 
   /**
-   * Determines whether the user can create a new Git ref.
+   * Checks whether the {@link CurrentUser} can create a new Git ref.
    *
    * @param repo repository on which user want to create
-   * @param object the object the user will start the reference with
-   * @param user the current identified user
    * @param branch the branch the new {@link RevObject} should be created on
-   * @return {@code null} if the user specified can create a new Git ref, or a String describing why
-   *     the creation is not allowed.
-   * @throws PermissionBackendException on failure of permission checks
+   * @param object the object the user will start the reference with
+   * @throws AuthException if creation is denied; the message explains the denial.
+   * @throws PermissionBackendException on failure of permission checks.
    */
-  @Nullable
-  public String canCreateRef(
-      Repository repo, RevObject object, IdentifiedUser user, Branch.NameKey branch)
-      throws PermissionBackendException, NoSuchProjectException, IOException {
+  public void checkCreateRef(Repository repo, Branch.NameKey branch, RevObject object)
+      throws AuthException, PermissionBackendException, NoSuchProjectException, IOException {
     ProjectState ps = projectCache.checkedGet(branch.getParentKey());
     if (ps == null) {
       throw new NoSuchProjectException(branch.getParentKey());
     }
     if (!ps.getProject().getState().permitsWrite()) {
-      return "project state does not permit write";
+      throw new AuthException("project state does not permit write");
     }
 
     PermissionBackend.ForRef perm = permissionBackend.user(user).ref(branch);
     if (object instanceof RevCommit) {
-      if (!testAuditLogged(perm, RefPermission.CREATE)) {
-        return user.getAccountId() + " lacks permission: " + Permission.CREATE;
-      }
-      return canCreateCommit(repo, (RevCommit) object, ps, user, perm);
+      perm.check(RefPermission.CREATE);
+      checkCreateCommit(repo, (RevCommit) object, ps, perm);
     } else if (object instanceof RevTag) {
-      final RevTag tag = (RevTag) object;
+      RevTag tag = (RevTag) object;
       try (RevWalk rw = new RevWalk(repo)) {
         rw.parseBody(tag);
       } catch (IOException e) {
-        String msg =
-            String.format("RevWalk(%s) for pushing tag %s:", branch.getParentKey(), tag.name());
-        log.error(msg, e);
-
-        return "I/O exception for revwalk";
+        log.error(String.format("RevWalk(%s) parsing %s:", branch.getParentKey(), tag.name()), e);
+        throw e;
       }
 
       // If tagger is present, require it matches the user's email.
-      //
-      final PersonIdent tagger = tag.getTaggerIdent();
-      if (tagger != null) {
-        boolean valid;
-        if (user.isIdentifiedUser()) {
-          final String addr = tagger.getEmailAddress();
-          valid = user.asIdentifiedUser().hasEmailAddress(addr);
-        } else {
-          valid = false;
-        }
-        if (!valid && !testAuditLogged(perm, RefPermission.FORGE_COMMITTER)) {
-          return user.getAccountId() + " lacks permission: " + Permission.FORGE_COMMITTER;
-        }
+      PersonIdent tagger = tag.getTaggerIdent();
+      if (tagger != null
+          && (!user.get().isIdentifiedUser()
+              || !user.get().asIdentifiedUser().hasEmailAddress(tagger.getEmailAddress()))) {
+        perm.check(RefPermission.FORGE_COMMITTER);
       }
 
-      RevObject tagObject = tag.getObject();
-      if (tagObject instanceof RevCommit) {
-        String rejectReason = canCreateCommit(repo, (RevCommit) tagObject, ps, user, perm);
-        if (rejectReason != null) {
-          return rejectReason;
-        }
+      RevObject target = tag.getObject();
+      if (target instanceof RevCommit) {
+        checkCreateCommit(repo, (RevCommit) target, ps, perm);
       } else {
-        String rejectReason = canCreateRef(repo, tagObject, user, branch);
-        if (rejectReason != null) {
-          return rejectReason;
-        }
+        checkCreateRef(repo, branch, target);
       }
 
       // If the tag has a PGP signature, allow a lower level of permission
       // than if it doesn't have a PGP signature.
-      //
-      RefControl refControl = ps.controlFor(user).controlForRef(branch);
+      RefControl refControl = ps.controlFor(user.get()).controlForRef(branch);
       if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
-        return refControl.canPerform(Permission.CREATE_SIGNED_TAG)
-            ? null
-            : user.getAccountId() + " lacks permission: " + Permission.CREATE_SIGNED_TAG;
+        if (!refControl.canPerform(Permission.CREATE_SIGNED_TAG)) {
+          throw new AuthException(Permission.CREATE_SIGNED_TAG + " not permitted");
+        }
+      } else if (!refControl.canPerform(Permission.CREATE_TAG)) {
+        throw new AuthException(Permission.CREATE_TAG + " not permitted");
       }
-      return refControl.canPerform(Permission.CREATE_TAG)
-          ? null
-          : user.getAccountId() + " lacks permission " + Permission.CREATE_TAG;
     }
-
-    return null;
   }
 
   /**
-   * Check if the user is allowed to create a new commit object if this introduces a new commit to
-   * the project. If not allowed, returns a string describing why it's not allowed. The userId
-   * argument is only used for the error message.
+   * Check if the user is allowed to create a new commit object if this creation would introduce a
+   * new commit to the repository.
    */
-  @Nullable
-  private String canCreateCommit(
-      Repository repo,
-      RevCommit commit,
-      ProjectState projectState,
-      IdentifiedUser user,
-      PermissionBackend.ForRef forRef)
-      throws PermissionBackendException {
-    if (projectState.controlFor(user).isReachableFromHeadsOrTags(repo, commit)) {
+  private void checkCreateCommit(
+      Repository repo, RevCommit commit, ProjectState projectState, PermissionBackend.ForRef forRef)
+      throws AuthException, PermissionBackendException {
+    try {
+      // If the user has update (push) permission, they can create the ref regardless
+      // of whether they are pushing any new objects along with the create.
+      forRef.check(RefPermission.UPDATE);
+      return;
+    } catch (AuthException denied) {
+      // Fall through to check reachability.
+    }
+
+    if (projectState.controlFor(user.get()).isReachableFromHeadsOrTags(repo, commit)) {
       // If the user has no push permissions, check whether the object is
       // merged into a branch or tag readable by this user. If so, they are
       // not effectively "pushing" more objects, so they can create the ref
       // even if they don't have push permission.
-      return null;
-    } else if (testAuditLogged(forRef, RefPermission.UPDATE)) {
-      // If the user has push permissions, they can create the ref regardless
-      // of whether they are pushing any new objects along with the create.
-      return null;
+      return;
     }
-    return user.getAccountId()
-        + " lacks permission "
-        + Permission.PUSH
-        + " for creating new commit object";
-  }
 
-  private boolean testAuditLogged(PermissionBackend.ForRef forRef, RefPermission p)
-      throws PermissionBackendException {
-    try {
-      forRef.check(p);
-    } catch (AuthException e) {
-      return false;
-    }
-    return true;
+    throw new AuthException(
+        String.format(
+            "%s for creating new commit object not permitted",
+            RefPermission.UPDATE.describeForException()));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
index 8c1c1e2..82462b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
@@ -34,7 +34,7 @@
   public static FileResource create(
       GitRepositoryManager repoManager, ProjectState projectState, ObjectId rev, String path)
       throws ResourceNotFoundException, IOException {
-    try (Repository repo = repoManager.openRepository(projectState.getProject().getNameKey());
+    try (Repository repo = repoManager.openRepository(projectState.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
       RevTree tree = rw.parseTree(rev);
       if (TreeWalk.forPath(repo, path, tree) != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
index 53e1baa..afffdfc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
@@ -39,6 +39,6 @@
     if (recursive || rsrc.isDirectChild()) {
       return json.format(rsrc.getChild().getProject());
     }
-    throw new ResourceNotFoundException(rsrc.getChild().getProject().getName());
+    throw new ResourceNotFoundException(rsrc.getChild().getName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
index 8a8e8d3..8c8314b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
@@ -32,6 +32,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.concurrent.Future;
 import org.eclipse.jgit.util.io.NullOutputStream;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
@@ -60,7 +61,11 @@
             .beginSubTask("", MultiProgressMonitor.UNKNOWN);
     AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
     allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
-    executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
+    // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
+    // return value.
+    @SuppressWarnings("unused")
+    Future<Void> ignored =
+        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
     return Response.accepted("Project " + project + " submitted for reindexing");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
index 23a4417..e5fe37d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
@@ -77,7 +77,7 @@
     for (Project.NameKey name : projectCache.all()) {
       ProjectState c = projectCache.get(name);
       if (c != null && parent.equals(c.getProject().getParent(allProjects))) {
-        children.put(c.getProject().getNameKey(), c.getProject());
+        children.put(c.getNameKey(), c.getProject());
       }
     }
     return permissionBackend
@@ -105,7 +105,7 @@
     for (Project.NameKey name : projectCache.all()) {
       ProjectState c = projectCache.get(name);
       if (c != null) {
-        projects.put(c.getProject().getNameKey(), c.getProject());
+        projects.put(c.getNameKey(), c.getProject());
       }
     }
     return projects;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
index e1d6c14..f81e84b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
@@ -93,7 +93,7 @@
   private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
     Map<Project.NameKey, ProjectState> tree = new LinkedHashMap<>();
     for (ProjectState ps : rsrc.getProjectState().tree()) {
-      tree.put(ps.getProject().getNameKey(), ps);
+      tree.put(ps.getNameKey(), ps);
     }
     tree.keySet()
         .retainAll(permissionBackend.user(user).filter(ProjectPermission.ACCESS, tree.keySet()));
@@ -102,10 +102,8 @@
 
   private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
       throws ResourceNotFoundException, IOException, PermissionBackendException {
-    Project.NameKey projectName = state.getProject().getNameKey();
-    PermissionBackend.ForProject perm =
-        permissionBackend.user(user).project(state.getProject().getNameKey());
-    try (Repository git = gitManager.openRepository(projectName);
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(state.getNameKey());
+    try (Repository git = gitManager.openRepository(state.getNameKey());
         RevWalk rw = new RevWalk(git)) {
       List<DashboardInfo> all = new ArrayList<>();
       for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index a284d7d..522aa89 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -373,12 +373,12 @@
           ProjectState parent = Iterables.getFirst(e.parents(), null);
           if (parent != null) {
             if (isParentAccessible(accessibleParents, perm, parent)) {
-              info.parent = parent.getProject().getName();
+              info.parent = parent.getName();
             } else {
-              info.parent = hiddenNames.get(parent.getProject().getName());
+              info.parent = hiddenNames.get(parent.getName());
               if (info.parent == null) {
                 info.parent = "?-" + (hiddenNames.size() + 1);
-                hiddenNames.put(parent.getProject().getName(), info.parent);
+                hiddenNames.put(parent.getName(), info.parent);
               }
             }
           }
@@ -506,8 +506,7 @@
           } else {
             log.warn(
                 String.format(
-                    "parent project %s of project %s not found",
-                    parent.get(), ps.getProject().getName()));
+                    "parent project %s of project %s not found", parent.get(), ps.getName()));
           }
         }
       }
@@ -518,7 +517,7 @@
   private boolean isParentAccessible(
       Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState p)
       throws PermissionBackendException {
-    Project.NameKey name = p.getProject().getNameKey();
+    Project.NameKey name = p.getNameKey();
     Boolean b = checked.get(name);
     if (b == null) {
       try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index 2011cd5..a3bee39 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -274,8 +274,7 @@
     if (!canPerformOnAnyRef(Permission.PUSH)
         && !canPerformOnAnyRef(Permission.CREATE_TAG)
         && !isOwner()) {
-      String pName = state.getProject().getName();
-      return new Capable("Upload denied for project '" + pName + "'");
+      return new Capable("Upload denied for project '" + state.getName() + "'");
     }
     if (state.isUseContributorAgreements()) {
       return verifyActiveContributorAgreement();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index 9d9e5bb..ac8d536 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -44,7 +44,7 @@
     allProjectsName = all;
 
     seen = Sets.newLinkedHashSet();
-    seen.add(firstResult.getProject().getNameKey());
+    seen.add(firstResult.getNameKey());
     next = firstResult;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index d4c344b..72e5ee6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -180,7 +180,7 @@
   }
 
   private boolean isRevisionOutOfDate() {
-    try (Repository git = gitMgr.openRepository(getProject().getNameKey())) {
+    try (Repository git = gitMgr.openRepository(getNameKey())) {
       Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
       if (ref == null || ref.getObjectId() == null) {
         return true;
@@ -203,7 +203,7 @@
   public PrologEnvironment newPrologEnvironment() throws CompileException {
     PrologMachineCopy pmc = rulesMachine;
     if (pmc == null) {
-      pmc = rulesCache.loadMachine(getProject().getNameKey(), config.getRulesId());
+      pmc = rulesCache.loadMachine(getNameKey(), config.getRulesId());
       rulesMachine = pmc;
     }
     return envFactory.create(pmc);
@@ -226,6 +226,14 @@
     return config.getProject();
   }
 
+  public Project.NameKey getNameKey() {
+    return getProject().getNameKey();
+  }
+
+  public String getName() {
+    return getNameKey().get();
+  }
+
   public ProjectConfig getConfig() {
     return config;
   }
@@ -236,10 +244,10 @@
     }
 
     ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
-    try (Repository git = gitMgr.openRepository(getProject().getNameKey())) {
+    try (Repository git = gitMgr.openRepository(getNameKey())) {
       cfg.load(git);
     } catch (IOException | ConfigInvalidException e) {
-      log.warn("Failed to load " + fileName + " for " + getProject().getName(), e);
+      log.warn("Failed to load " + fileName + " for " + getName(), e);
     }
 
     configs.put(fileName, cfg);
@@ -268,7 +276,7 @@
           section.setPermissions(copy);
         }
 
-        SectionMatcher matcher = SectionMatcher.wrap(getProject().getNameKey(), section);
+        SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
         if (matcher != null) {
           sm.add(matcher);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index 0775bcf..c4a7eb4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -105,7 +105,7 @@
 
   public ConfigInfo apply(ProjectState projectState, ConfigInput input)
       throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
-    Project.NameKey projectName = projectState.getProject().getNameKey();
+    Project.NameKey projectName = projectState.getNameKey();
     if (input == null) {
       throw new BadRequestException("config is required");
     }
@@ -309,7 +309,7 @@
       throw new BadRequestException(
           String.format(
               "Not allowed to set parameter '%s' of plugin '%s' on project '%s'.",
-              parameterName, pluginName, projectState.getProject().getName()));
+              parameterName, pluginName, projectState.getName()));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index a749759..3736360 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -323,27 +323,6 @@
     return canForcePerform(Permission.EDIT_TOPIC_NAME);
   }
 
-  /** All value ranges of any allowed label permission. */
-  List<PermissionRange> getLabelRanges(boolean isChangeOwner) {
-    List<PermissionRange> r = new ArrayList<>();
-    for (Map.Entry<String, List<PermissionRule>> e : relevant.getDeclaredPermissions()) {
-      if (Permission.isLabel(e.getKey())) {
-        int min = 0;
-        int max = 0;
-        for (PermissionRule rule : e.getValue()) {
-          if (projectControl.match(rule, isChangeOwner)) {
-            min = Math.min(min, rule.getMin());
-            max = Math.max(max, rule.getMax());
-          }
-        }
-        if (min != 0 || max != 0) {
-          r.add(new PermissionRange(e.getKey(), min, max));
-        }
-      }
-    }
-    return r;
-  }
-
   /** The range of permitted values associated with a label permission. */
   PermissionRange getRange(String permission) {
     return getRange(permission, false);
@@ -609,7 +588,7 @@
         case UPDATE_BY_SUBMIT:
           return projectControl.controlForRef("refs/for/" + getRefName()).canSubmit(true);
 
-        case BYPASS_REVIEW:
+        case SKIP_VALIDATION:
           return canForgeAuthor()
               && canForgeCommitter()
               && canForgeGerritServerIdentity()
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 591fcc2..b042183 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -87,7 +87,9 @@
       }
     }
     // The change owner may remove any zero or positive score.
-    if (changeControl.isOwner() && 0 <= value) {
+    if (currentUser.isIdentifiedUser()
+        && currentUser.getAccountId().equals(notes.getChange().getOwner())
+        && 0 <= value) {
       return true;
     }
     // Users with the remove reviewer permission, the branch owner, project
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index 9cba53b..e875388 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -14,18 +14,10 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -37,10 +29,8 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -49,44 +39,35 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
   private final PermissionBackend permissionBackend;
-  private final GroupsCollection groupsCollection;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final AllProjectsName allProjects;
-  private final Provider<SetParent> setParent;
   private final GetAccess getAccess;
   private final ProjectCache projectCache;
   private final Provider<IdentifiedUser> identifiedUser;
+  private final SetAccessUtil accessUtil;
 
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
       PermissionBackend permissionBackend,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent,
-      GroupsCollection groupsCollection,
       ProjectCache projectCache,
       GetAccess getAccess,
-      Provider<IdentifiedUser> identifiedUser) {
+      Provider<IdentifiedUser> identifiedUser,
+      SetAccessUtil accessUtil) {
     this.groupBackend = groupBackend;
     this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allProjects = allProjects;
-    this.setParent = setParent;
-    this.groupsCollection = groupsCollection;
     this.getAccess = getAccess;
     this.projectCache = projectCache;
     this.identifiedUser = identifiedUser;
+    this.accessUtil = accessUtil;
   }
 
   @Override
@@ -94,113 +75,39 @@
       throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
           BadRequestException, UnprocessableEntityException, OrmException,
           PermissionBackendException {
-    List<AccessSection> removals = getAccessSections(input.remove);
-    List<AccessSection> additions = getAccessSections(input.add);
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
-    ProjectControl projectControl = rsrc.getControl();
     ProjectConfig config;
 
-    Project.NameKey newParentProjectName =
-        input.parent == null ? null : new Project.NameKey(input.parent);
-
+    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
+    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = ProjectConfig.read(md);
 
-      // Perform removal checks
-      for (AccessSection section : removals) {
+      // Check that the user has the right permissions.
+      boolean checkedAdmin = false;
+      for (AccessSection section : Iterables.concat(additions, removals)) {
         boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-
         if (isGlobalCapabilities) {
-          checkGlobalCapabilityPermissions(config.getName());
-        } else if (!projectControl.controlForRef(section.getName()).isOwner()) {
+          if (!checkedAdmin) {
+            permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
+            checkedAdmin = true;
+          }
+        } else if (!rsrc.getControl().controlForRef(section.getName()).isOwner()) {
           throw new AuthException(
-              "You are not allowed to edit permissionsfor ref: " + section.getName());
-        }
-      }
-      // Perform addition checks
-      for (AccessSection section : additions) {
-        String name = section.getName();
-        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
-
-        if (isGlobalCapabilities) {
-          checkGlobalCapabilityPermissions(config.getName());
-        } else {
-          if (!AccessSection.isValid(name)) {
-            throw new BadRequestException("invalid section name");
-          }
-          if (!projectControl.controlForRef(name).isOwner()) {
-            throw new AuthException("You are not allowed to edit permissionsfor ref: " + name);
-          }
-          RefPattern.validate(name);
-        }
-
-        // Check all permissions for soundness
-        for (Permission p : section.getPermissions()) {
-          if (isGlobalCapabilities && !GlobalCapability.isCapability(p.getName())) {
-            throw new BadRequestException(
-                "Cannot add non-global capability " + p.getName() + " to global capabilities");
-          }
+              "You are not allowed to edit permissions for ref: " + section.getName());
         }
       }
 
-      // Apply removals
-      for (AccessSection section : removals) {
-        if (section.getPermissions().isEmpty()) {
-          // Remove entire section
-          config.remove(config.getAccessSection(section.getName()));
-        }
-        // Remove specific permissions
-        for (Permission p : section.getPermissions()) {
-          if (p.getRules().isEmpty()) {
-            config.remove(config.getAccessSection(section.getName()), p);
-          } else {
-            for (PermissionRule r : p.getRules()) {
-              config.remove(config.getAccessSection(section.getName()), p, r);
-            }
-          }
-        }
-      }
+      accessUtil.validateChanges(config, removals, additions);
+      accessUtil.applyChanges(config, removals, additions);
 
-      // Apply additions
-      for (AccessSection section : additions) {
-        AccessSection currentAccessSection = config.getAccessSection(section.getName());
-
-        if (currentAccessSection == null) {
-          // Add AccessSection
-          config.replace(section);
-        } else {
-          for (Permission p : section.getPermissions()) {
-            Permission currentPermission = currentAccessSection.getPermission(p.getName());
-            if (currentPermission == null) {
-              // Add Permission
-              currentAccessSection.addPermission(p);
-            } else {
-              for (PermissionRule r : p.getRules()) {
-                // AddPermissionRule
-                currentPermission.add(r);
-              }
-            }
-          }
-        }
-      }
-
-      if (newParentProjectName != null
-          && !config.getProject().getNameKey().equals(allProjects)
-          && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
-        try {
-          setParent
-              .get()
-              .validateParentUpdate(
-                  projectControl.getProject().getNameKey(),
-                  projectControl.getUser().asIdentifiedUser(),
-                  MoreObjects.firstNonNull(newParentProjectName, allProjects).get(),
-                  true);
-        } catch (UnprocessableEntityException e) {
-          throw new ResourceConflictException(e.getMessage(), e);
-        }
-        config.getProject().setParentName(newParentProjectName);
-      }
+      accessUtil.setParentName(
+          identifiedUser.get(),
+          config,
+          rsrc.getNameKey(),
+          input.parent == null ? null : new Project.NameKey(input.parent),
+          !checkedAdmin);
 
       if (!Strings.isNullOrEmpty(input.message)) {
         if (!input.message.endsWith("\n")) {
@@ -221,68 +128,4 @@
 
     return getAccess.apply(rsrc.getNameKey());
   }
-
-  private List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
-      throws UnprocessableEntityException {
-    if (sectionInfos == null) {
-      return Collections.emptyList();
-    }
-
-    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
-    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
-      AccessSection accessSection = new AccessSection(entry.getKey());
-
-      if (entry.getValue().permissions == null) {
-        continue;
-      }
-
-      for (Map.Entry<String, PermissionInfo> permissionEntry :
-          entry.getValue().permissions.entrySet()) {
-        Permission p = new Permission(permissionEntry.getKey());
-        if (permissionEntry.getValue().exclusive != null) {
-          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
-        }
-
-        if (permissionEntry.getValue().rules == null) {
-          continue;
-        }
-        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
-            permissionEntry.getValue().rules.entrySet()) {
-          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-
-          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
-          if (group == null) {
-            throw new UnprocessableEntityException(
-                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
-          }
-          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
-          if (pri != null) {
-            if (pri.max != null) {
-              r.setMax(pri.max);
-            }
-            if (pri.min != null) {
-              r.setMin(pri.min);
-            }
-            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
-            if (pri.force != null) {
-              r.setForce(pri.force);
-            }
-          }
-          p.add(r);
-        }
-        accessSection.getPermissions().add(p);
-      }
-      sections.add(accessSection);
-    }
-    return sections;
-  }
-
-  private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
-      throws BadRequestException, AuthException, PermissionBackendException {
-    if (!allProjects.equals(projectName)) {
-      throw new BadRequestException(
-          "Cannot edit global capabilities for projects other than " + allProjects.get());
-    }
-    permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
new file mode 100644
index 0000000..848d68c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
@@ -0,0 +1,224 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class SetAccessUtil {
+  private final GroupsCollection groupsCollection;
+  private final AllProjectsName allProjects;
+  private final Provider<SetParent> setParent;
+
+  @Inject
+  private SetAccessUtil(
+      GroupsCollection groupsCollection,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent) {
+    this.groupsCollection = groupsCollection;
+    this.allProjects = allProjects;
+    this.setParent = setParent;
+  }
+
+  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+      throws UnprocessableEntityException {
+    if (sectionInfos == null) {
+      return Collections.emptyList();
+    }
+
+    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
+      if (entry.getValue().permissions == null) {
+        continue;
+      }
+
+      AccessSection accessSection = new AccessSection(entry.getKey());
+      for (Map.Entry<String, PermissionInfo> permissionEntry :
+          entry.getValue().permissions.entrySet()) {
+        if (permissionEntry.getValue().rules == null) {
+          continue;
+        }
+
+        Permission p = new Permission(permissionEntry.getKey());
+        if (permissionEntry.getValue().exclusive != null) {
+          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
+        }
+
+        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
+            permissionEntry.getValue().rules.entrySet()) {
+          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
+          if (group == null) {
+            throw new UnprocessableEntityException(
+                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          }
+
+          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
+          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
+          if (pri != null) {
+            if (pri.max != null) {
+              r.setMax(pri.max);
+            }
+            if (pri.min != null) {
+              r.setMin(pri.min);
+            }
+            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
+            if (pri.force != null) {
+              r.setForce(pri.force);
+            }
+          }
+          p.add(r);
+        }
+        accessSection.getPermissions().add(p);
+      }
+      sections.add(accessSection);
+    }
+    return sections;
+  }
+
+  /**
+   * Checks that the removals and additions are logically valid, but doesn't check current user's
+   * permission.
+   */
+  void validateChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions)
+      throws BadRequestException, InvalidNameException {
+    // Perform permission checks
+    for (AccessSection section : Iterables.concat(additions, removals)) {
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+      if (isGlobalCapabilities) {
+        if (!allProjects.equals(config.getName())) {
+          throw new BadRequestException(
+              "Cannot edit global capabilities for projects other than " + allProjects.get());
+        }
+      }
+    }
+
+    // Perform addition checks
+    for (AccessSection section : additions) {
+      String name = section.getName();
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
+
+      if (!isGlobalCapabilities) {
+        if (!AccessSection.isValid(name)) {
+          throw new BadRequestException("invalid section name");
+        }
+        RefPattern.validate(name);
+      } else {
+        // Check all permissions for soundness
+        for (Permission p : section.getPermissions()) {
+          if (!GlobalCapability.isCapability(p.getName())) {
+            throw new BadRequestException(
+                "Cannot add non-global capability " + p.getName() + " to global capabilities");
+          }
+        }
+      }
+    }
+  }
+
+  void applyChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions) {
+    // Apply removals
+    for (AccessSection section : removals) {
+      if (section.getPermissions().isEmpty()) {
+        // Remove entire section
+        config.remove(config.getAccessSection(section.getName()));
+        continue;
+      }
+
+      // Remove specific permissions
+      for (Permission p : section.getPermissions()) {
+        if (p.getRules().isEmpty()) {
+          config.remove(config.getAccessSection(section.getName()), p);
+        } else {
+          for (PermissionRule r : p.getRules()) {
+            config.remove(config.getAccessSection(section.getName()), p, r);
+          }
+        }
+      }
+    }
+
+    // Apply additions
+    for (AccessSection section : additions) {
+      AccessSection currentAccessSection = config.getAccessSection(section.getName());
+
+      if (currentAccessSection == null) {
+        // Add AccessSection
+        config.replace(section);
+      } else {
+        for (Permission p : section.getPermissions()) {
+          Permission currentPermission = currentAccessSection.getPermission(p.getName());
+          if (currentPermission == null) {
+            // Add Permission
+            currentAccessSection.addPermission(p);
+          } else {
+            for (PermissionRule r : p.getRules()) {
+              // AddPermissionRule
+              currentPermission.add(r);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  void setParentName(
+      IdentifiedUser identifiedUser,
+      ProjectConfig config,
+      Project.NameKey projectName,
+      Project.NameKey newParentProjectName,
+      boolean checkAdmin)
+      throws ResourceConflictException, AuthException, PermissionBackendException {
+    if (newParentProjectName != null
+        && !config.getProject().getNameKey().equals(allProjects)
+        && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
+      try {
+        setParent
+            .get()
+            .validateParentUpdate(
+                projectName, identifiedUser, newParentProjectName.get(), checkAdmin);
+      } catch (UnprocessableEntityException e) {
+        throw new ResourceConflictException(e.getMessage(), e);
+      }
+      config.getProject().setParentName(newParentProjectName);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
index 07f7ead..37cfcdd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
@@ -77,8 +77,7 @@
     IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
-    validateParentUpdate(
-        rsrc.getProjectState().getProject().getNameKey(), user, parentName, checkIfAdmin);
+    validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
     try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
       ProjectConfig config = ProjectConfig.read(md);
       Project project = config.getProject();
@@ -128,11 +127,11 @@
       if (Iterables.tryFind(
               parent.tree(),
               p -> {
-                return p.getProject().getNameKey().equals(project);
+                return p.getNameKey().equals(project);
               })
           .isPresent()) {
         throw new ResourceConflictException(
-            "cycle exists between " + project.get() + " and " + parent.getProject().getName());
+            "cycle exists between " + project.get() + " and " + parent.getName());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index 7274100..4595933 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -600,8 +600,7 @@
       try {
         parentEnv = parentState.newPrologEnvironment();
       } catch (CompileException err) {
-        throw new RuleEvalException(
-            "Cannot consult rules.pl for " + parentState.getProject().getName(), err);
+        throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
       }
 
       parentEnv.copyStoredValues(childEnv);
@@ -619,12 +618,12 @@
         throw new RuleEvalException(
             String.format(
                 "%s on change %d of %s",
-                err.getMessage(), cd.getId().get(), parentState.getProject().getName()));
+                err.getMessage(), cd.getId().get(), parentState.getName()));
       } catch (RuntimeException err) {
         throw new RuleEvalException(
             String.format(
                 "Exception calling %s on change %d of %s",
-                filterRule, cd.getId().get(), parentState.getProject().getName()),
+                filterRule, cd.getId().get(), parentState.getName()),
             err);
       } finally {
         reductionsConsumed += env.getReductions();
@@ -690,6 +689,6 @@
   }
 
   private String getProjectName() {
-    return control.getProjectControl().getProjectState().getProject().getName();
+    return control.getProjectControl().getProjectState().getName();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index f5e8d69..19c0515 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -56,7 +56,7 @@
     }
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
-    r.add(new ProjectPredicate(projectState.getProject().getName()));
+    r.add(new ProjectPredicate(projectState.getName()));
     try {
       ProjectResource proj = new ProjectResource(projectState.controlFor(self.get()));
       ListChildProjects children = listChildProjects.get();
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 ea9af4c..0153004 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
@@ -2330,7 +2330,7 @@
 
     PatchSetInserter inserter =
         patchSetFactory
-            .create(ctl, new PatchSet.Id(c.getId(), n), commit)
+            .create(ctl.getNotes(), new PatchSet.Id(c.getId(), n), commit)
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(false)
             .setValidate(false);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 58492b2..b9a98b9 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -69,8 +69,7 @@
 
             @Override
             public Project.NameKey getProjectName() {
-              Project project = projectControl.getProjectState().getProject();
-              return project.getNameKey();
+              return projectControl.getProjectState().getNameKey();
             }
           });
     } finally {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index 35788fd..0d7fa24 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -226,6 +226,6 @@
     if (ps == null) {
       return Collections.emptySet();
     }
-    return ps.parents().transform(s -> s.getProject().getNameKey()).toSet();
+    return ps.parents().transform(s -> s.getNameKey()).toSet();
   }
 }
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
index 0a75bfc..f2d07ed 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -395,7 +395,7 @@
 
     MyParser(Object bean) {
       super(bean);
-      parseAdditionalOptions("", bean, new HashSet<>());
+      parseAdditionalOptions(bean, new HashSet<>());
       ensureOptionsInitialized();
     }
 
@@ -433,7 +433,7 @@
       }
     }
 
-    private void parseAdditionalOptions(String prefix, Object bean, Set<Object> parsedBeans) {
+    private void parseAdditionalOptions(Object bean, Set<Object> parsedBeans) {
       for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
         for (Field f : c.getDeclaredFields()) {
           if (f.isAnnotationPresent(Options.class)) {
@@ -443,8 +443,7 @@
             } catch (IllegalAccessException e) {
               throw new IllegalAnnotationError(e);
             }
-            parseWithPrefix(
-                prefix + f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
+            parseWithPrefix(f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
           }
         }
       }
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index 57429f3..025b93e 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -3,8 +3,12 @@
 # Roboto Mono. Version 2.136
 # https://github.com/google/roboto/releases/tag/v2.136
 filegroup(
-    name = "robotomono",
+    name = "robotofonts",
     srcs = [
+        "Roboto-Medium.woff",
+        "Roboto-Medium.woff2",
+        "Roboto-Regular.woff",
+        "Roboto-Regular.woff2",
         "RobotoMono-Regular.woff",
         "RobotoMono-Regular.woff2",
     ],
diff --git a/lib/fonts/Roboto-Medium.woff b/lib/fonts/Roboto-Medium.woff
new file mode 100644
index 0000000..720bd3e
--- /dev/null
+++ b/lib/fonts/Roboto-Medium.woff
Binary files differ
diff --git a/lib/fonts/Roboto-Medium.woff2 b/lib/fonts/Roboto-Medium.woff2
new file mode 100644
index 0000000..c003fba
--- /dev/null
+++ b/lib/fonts/Roboto-Medium.woff2
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff b/lib/fonts/Roboto-Regular.woff
new file mode 100644
index 0000000..03e84eb
--- /dev/null
+++ b/lib/fonts/Roboto-Regular.woff
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff2 b/lib/fonts/Roboto-Regular.woff2
new file mode 100644
index 0000000..6fa4939
--- /dev/null
+++ b/lib/fonts/Roboto-Regular.woff2
Binary files differ
diff --git a/plugins/replication b/plugins/replication
index 297b749..643d635 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 297b749038153527291b43cb08b162eb475adcd7
+Subproject commit 643d635a4502e2a2df6cb02edade88bad3fd953a
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index d4d2322..31ab6aa 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -29,7 +29,7 @@
 genrule2(
     name = "fonts",
     srcs = [
-        "//lib/fonts:robotomono",
+        "//lib/fonts:robotofonts",
     ],
     outs = ["fonts.zip"],
     cmd = " && ".join([
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
index c2b28d5..529e14c 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
@@ -109,7 +109,8 @@
                 permission="{{permission}}"
                 labels="[[labels]]"
                 section="[[section.id]]"
-                editing="[[editing]]">
+                editing="[[editing]]"
+                groups="[[groups]]">
             </gr-permission>
           </template>
           <div id="addPermission">
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
index 16e6207..07a7a62 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -33,6 +33,7 @@
         notify: true,
         observer: '_sectionChanged',
       },
+      groups: Object,
       labels: Object,
       editing: {
         type: Boolean,
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
index 00aeb91..72439e2 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -70,6 +70,7 @@
     attached() {
       this._getCreateGroupCapability();
       this.fire('title-change', {title: 'Groups'});
+      this._maybeOpenCreateOverlay(this.params);
     },
 
     _paramsChanged(params) {
@@ -81,6 +82,16 @@
           this._offset);
     },
 
+    /**
+     * Opens the create overlay if the route has a hash 'create'
+     * @param {!Object} params
+     */
+    _maybeOpenCreateOverlay(params) {
+      if (params && params.openCreateModal) {
+        this.$.createOverlay.open();
+      }
+    },
+
     _computeGroupUrl(id) {
       return this.getUrl(this._path + '/', id);
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
index 4cc1afe..06428c7 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -90,6 +90,18 @@
       test('_shownGroups', () => {
         assert.equal(element._shownGroups.length, 25);
       });
+
+      test('_maybeOpenCreateOverlay', () => {
+        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+        element._maybeOpenCreateOverlay();
+        assert.isFalse(overlayOpen.called);
+        const params = {};
+        element._maybeOpenCreateOverlay(params);
+        assert.isFalse(overlayOpen.called);
+        params.openCreateModal = true;
+        element._maybeOpenCreateOverlay(params);
+        assert.isTrue(overlayOpen.called);
+      });
     });
 
     suite('test with less then 25 groups', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
index c804933..928c24e 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -24,10 +24,7 @@
 <link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
 <link rel="import" href="../../shared/gr-placeholder/gr-placeholder.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../gr-create-group-dialog/gr-create-group-dialog.html">
-<link rel="import" href="../gr-create-project-dialog/gr-create-project-dialog.html">
 <link rel="import" href="../gr-admin-group-list/gr-admin-group-list.html">
-<link rel="import" href="../gr-admin-project-list/gr-admin-project-list.html">
 <link rel="import" href="../gr-group/gr-group.html">
 <link rel="import" href="../gr-group-audit-log/gr-group-audit-log.html">
 <link rel="import" href="../gr-group-members/gr-group-members.html">
@@ -36,6 +33,7 @@
 <link rel="import" href="../gr-project-access/gr-project-access.html">
 <link rel="import" href="../gr-project-commands/gr-project-commands.html">
 <link rel="import" href="../gr-project-detail-list/gr-project-detail-list.html">
+<link rel="import" href="../gr-project-list/gr-project-list.html">
 
 <dom-module id="gr-admin-view">
   <template>
@@ -75,8 +73,7 @@
     </gr-page-nav>
     <template is="dom-if" if="[[_showProjectList]]" restamp="true">
       <main class="table">
-        <gr-admin-project-list class="table" params="[[params]]">
-        </gr-admin-project-list>
+        <gr-project-list class="table" params="[[params]]"></gr-project-list>
       </main>
     </template>
     <template is="dom-if" if="[[_showProjectMain]]" restamp="true">
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index e75c636..2d8da15 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -17,7 +17,7 @@
   const ADMIN_LINKS = [{
     name: 'Projects',
     url: '/admin/projects',
-    view: 'gr-admin-project-list',
+    view: 'gr-project-list',
     viewableToAll: true,
     children: [],
   }, {
@@ -191,7 +191,7 @@
           params.adminView === 'gr-project-commands');
       this.set('_showProjectMain', params.adminView === 'gr-project');
       this.set('_showProjectList',
-          params.adminView === 'gr-admin-project-list');
+          params.adminView === 'gr-project-list');
       this.set('_showProjectDetailList',
           params.adminView === 'gr-project-detail-list');
       this.set('_showPluginList', params.adminView === 'gr-plugin-list');
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
index 746b7bb..117940a3 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -70,18 +70,18 @@
       element._filteredLinks = [{
         name: 'Projects',
         url: '/admin/projects',
-        view: 'gr-admin-project-list',
+        view: 'gr-project-list',
         children: [],
       }];
 
       element.params = {
-        adminView: 'gr-admin-project-list',
+        adminView: 'gr-project-list',
       };
 
       flushAsynchronousOperations();
       assert.equal(Polymer.dom(element.root).querySelectorAll(
           '.selected').length, 1);
-      assert.ok(element.$$('gr-admin-project-list'));
+      assert.ok(element.$$('gr-project-list'));
       assert.isNotOk(element.$$('gr-admin-create-project'));
     });
 
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
index bfced55..9371b17 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -64,7 +64,7 @@
       }
       th {
         border-bottom: 1px solid #eee;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         text-align: left;
       }
     </style>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
index 1dfb06c..31171f7 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
@@ -92,7 +92,8 @@
             <gr-rule-editor
                 label="[[_label]]"
                 editing="[[editing]]"
-                group="[[rule.id]]"
+                group-id="[[rule.id]]"
+                group-name="[[_computeGroupName(groups, rule.id)]]"
                 permission="[[permission.id]]"
                 rule="{{rule}}"
                 section="[[section]]"></gr-rule-editor>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
index 97d4fe5..b99327c 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -28,6 +28,7 @@
         observer: '_sortPermission',
         notify: true,
       },
+      groups: Object,
       section: String,
       editing: {
         type: Boolean,
@@ -129,6 +130,11 @@
       return groups;
     },
 
+    _computeGroupName(groups, groupId) {
+      return groups && groups[groupId] && groups[groupId].name ?
+          groups[groupId].name : groupId;
+    },
+
     _getGroupSuggestions() {
       return this.$.restAPI.getSuggestedGroups(
           this._groupFilter,
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
index 3c229d1..73eac54 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -158,6 +158,14 @@
             'editing deleted');
       });
 
+      test('_computeGroupName', () => {
+        const groups = {
+          abc123: {name: 'test group'},
+          bcd234: {},
+        };
+        assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
+        assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
+      });
 
       test('_computeGroupsWithRules', () => {
         const rules = [
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html
index d776763..686aa10 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html
@@ -40,7 +40,8 @@
             capabilities="[[_capabilities]]"
             section="{{section}}"
             labels="[[_labels]]"
-            editing="[[_editing]]"></gr-access-section>
+            editing="[[_editing]]"
+            groups="[[_groups]]"></gr-access-section>
       </template>
       <template is="dom-if" if="[[_inheritsFrom]]">
         <h3 id="inheritsFrom">Rights Inherit From
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js
index 808279a..8e1b1f5 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js
@@ -24,6 +24,7 @@
       },
 
       _capabilities: Object,
+      _groups: Object,
       /** @type {?} */
       _inheritsFrom: Object,
       _labels: Object,
@@ -41,7 +42,12 @@
       Gerrit.URLEncodingBehavior,
     ],
 
+    /**
+     * @param {string} project
+     * @return {!Promise}
+     */
     _projectChanged(project) {
+      if (!project) { return Promise.resolve(); }
       const promises = [];
       if (!this._sections) {
         this._sections = [];
@@ -49,6 +55,7 @@
       promises.push(this.$.restAPI.getProjectAccessRights(project).then(res => {
         this._inheritsFrom = res.inherits_from;
         this._local = res.local;
+        this._groups = res.groups;
         return this.toSortedArray(this._local);
       }));
 
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html
index ef7412e..70b0f8c 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html
@@ -78,7 +78,7 @@
         },
       };
       const accessStub = sandbox.stub(element.$.restAPI,
-          'getProjectAccessRights') .returns(Promise.resolve(accessRes));
+          'getProjectAccessRights').returns(Promise.resolve(accessRes));
       const capabilitiesStub = sandbox.stub(element.$.restAPI,
           'getCapabilities').returns(Promise.resolve(capabilitiesRes));
       const projectStub = sandbox.stub(element.$.restAPI, 'getProject').returns(
@@ -97,6 +97,46 @@
       });
     });
 
+    test('_projectChanged when project changes to undefined returns', done => {
+      const capabilitiesRes = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+      };
+      const accessRes = {
+        local: {
+          GLOBAL_CAPABILITIES: {
+            permissions: {
+              accessDatabase: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      const projectRes = {
+        labels: {
+          'Code-Review': {},
+        },
+      };
+      const accessStub = sandbox.stub(element.$.restAPI,
+          'getProjectAccessRights').returns(Promise.resolve(accessRes));
+      const capabilitiesStub = sandbox.stub(element.$.restAPI,
+          'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+      const projectStub = sandbox.stub(element.$.restAPI, 'getProject').returns(
+          Promise.resolve(projectRes));
+
+      element._projectChanged().then(() => {
+        assert.isFalse(accessStub.called);
+        assert.isFalse(capabilitiesStub.called);
+        assert.isFalse(projectStub.called);
+        done();
+      });
+    });
+
     test('_computeParentHref', () => {
       const projectName = 'test-project';
       assert.equal(element._computeParentHref(projectName),
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
similarity index 97%
rename from polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.html
rename to polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
index 1b6da09..6c45704 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
@@ -26,7 +26,7 @@
 <link rel="import" href="../gr-create-project-dialog/gr-create-project-dialog.html">
 
 
-<dom-module id="gr-admin-project-list">
+<dom-module id="gr-project-list">
   <template>
     <style include="shared-styles"></style>
     <style include="gr-table-styles"></style>
@@ -94,5 +94,5 @@
     </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
-  <script src="gr-admin-project-list.js"></script>
+  <script src="gr-project-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.js b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
similarity index 91%
rename from polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.js
rename to polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
index 4f93e98..070cc2f 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
@@ -15,7 +15,7 @@
   'use strict';
 
   Polymer({
-    is: 'gr-admin-project-list',
+    is: 'gr-project-list',
 
     properties: {
       /**
@@ -70,6 +70,7 @@
     attached() {
       this._getCreateProjectCapability();
       this.fire('title-change', {title: 'Projects'});
+      this._maybeOpenCreateOverlay(this.params);
     },
 
     _paramsChanged(params) {
@@ -81,6 +82,16 @@
           this._offset);
     },
 
+    /**
+     * Opens the create overlay if the route has a hash 'create'
+     * @param {!Object} params
+     */
+    _maybeOpenCreateOverlay(params) {
+      if (params && params.openCreateModal) {
+        this.$.createOverlay.open();
+      }
+    },
+
     _computeProjectUrl(name) {
       return this.getUrl(this._path + '/', name);
     },
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
similarity index 88%
rename from polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list_test.html
rename to polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
index ee3f832..87732b8 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-project-list/gr-admin-project-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
@@ -16,19 +16,19 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-admin-project-list</title>
+<title>gr-project-list</title>
 
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
 <link rel="import" href="../../../test/common-test-setup.html"/>
-<link rel="import" href="gr-admin-project-list.html">
+<link rel="import" href="gr-project-list.html">
 
 <script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
-    <gr-admin-project-list></gr-admin-project-list>
+    <gr-project-list></gr-project-list>
   </template>
 </test-fixture>
 
@@ -47,7 +47,7 @@
     };
   };
 
-  suite('gr-admin-project-list tests', () => {
+  suite('gr-project-list tests', () => {
     let element;
     let projects;
     let sandbox;
@@ -66,13 +66,11 @@
     suite('list with projects', () => {
       setup(done => {
         projects = _.times(26, projectGenerator);
-
         stub('gr-rest-api-interface', {
           getProjects(num, offset) {
             return Promise.resolve(projects);
           },
         });
-
         element._paramsChanged(value).then(() => { flush(done); });
       });
 
@@ -86,6 +84,18 @@
       test('_shownProjects', () => {
         assert.equal(element._shownProjects.length, 25);
       });
+
+      test('_maybeOpenCreateOverlay', () => {
+        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+        element._maybeOpenCreateOverlay();
+        assert.isFalse(overlayOpen.called);
+        const params = {};
+        element._maybeOpenCreateOverlay(params);
+        assert.isFalse(overlayOpen.called);
+        params.openCreateModal = true;
+        element._maybeOpenCreateOverlay(params);
+        assert.isTrue(overlayOpen.called);
+      });
     });
 
     suite('list with less then 25 projects', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
index 0a8f382..18612c8 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
@@ -16,7 +16,9 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -64,6 +66,9 @@
       #deletedContainer.deleted {
         display: block;
       }
+      .groupPath {
+        color: #666;
+      }
     </style>
     <style include="gr-form-styles"></style>
     <div id="mainContainer"
@@ -100,7 +105,9 @@
             </select>
           </gr-select>
         </template>
-        <span>[[group]]</span>
+        <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
+          [[groupName]]
+        </a>
         <gr-select
             id="force"
             class$="[[_computeForceClass(permission)]]"
@@ -126,7 +133,7 @@
     <div
         id="deletedContainer"
         class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
-      [[group]] was deleted
+      [[groupName]] was deleted
       <gr-button id="undoRemoveBtn" on-tap="_handleUndoRemove">Undo</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
index 6f404bf..7f1a245 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -57,7 +57,8 @@
         type: Boolean,
         value: false,
       },
-      group: String,
+      groupId: String,
+      groupName: String,
       permission: String,
       /** @type {?} */
       rule: {
@@ -78,6 +79,8 @@
 
     behaviors: [
       Gerrit.AccessBehavior,
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     observers: [
@@ -107,6 +110,10 @@
       return this._computeForce(permission) ? 'force' : '';
     },
 
+    _computeGroupPath(group) {
+      return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
+    },
+
     _computeSectionClass(editing, deleted) {
       const classList = [];
       if (editing) {
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
index 2b02419..3594e4b 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -240,6 +240,12 @@
         MockInteractions.tap(element.$.undoRemoveBtn);
         assert.isNotOk(element.rule.value.deleted);
       });
+
+      test('_computeGroupPath', () => {
+        const group = '123';
+        assert.equal(element._computeGroupPath(group),
+            `/admin/groups/123`);
+      });
     });
 
     suite('new edit rule', () => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index 70275b3..4ff10cb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -39,7 +39,7 @@
         background-color: #ebf5fb;
       }
       :host([needs-review]) {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       :host([assigned]) {
         background-color: #fcfad6;
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index bec7429..f04b7c6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -16,8 +16,9 @@
 
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-dashboard-view">
   <template>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 18ba163..f2ed33a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -74,9 +74,12 @@
           margin: .5em;
           text-align: center;
         }
+        #mainContent.mobileOverlayOpened {
+          display: none;
+        }
       }
     </style>
-    <div>
+    <div id="mainContent">
       <span
           id="actionLoadingMessage"
           hidden$="[[!_actionLoadingMessage]]">
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 4691836..3bccdd8 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -291,6 +291,11 @@
       '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
     ],
 
+    listeners: {
+      'fullscreen-overlay-opened': '_handleHideBackgroundContent',
+      'fullscreen-overlay-closed': '_handleShowBackgroundContent',
+    },
+
     ready() {
       this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
       this._loading = false;
@@ -939,6 +944,14 @@
       this._fireAction('/wip', this.actions.wip, false);
     },
 
+    _handleHideBackgroundContent() {
+      this.$.mainContent.classList.add('overlayOpen');
+    },
+
+    _handleShowBackgroundContent() {
+      this.$.mainContent.classList.remove('overlayOpen');
+    },
+
     /**
      * Merge sources of change actions into a single ordered array of action
      * values.
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 2390c21..55cd982 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -346,6 +346,20 @@
       });
     });
 
+    test('fullscreen-overlay-opened hides content', () => {
+      sandbox.spy(element, '_handleHideBackgroundContent');
+      element.$.overlay.fire('fullscreen-overlay-opened');
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      sandbox.spy(element, '_handleShowBackgroundContent');
+      element.$.overlay.fire('fullscreen-overlay-closed');
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
     suite('cherry-pick', () => {
       let fireActionStub;
 
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 5a834b7..388a3f7 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -43,7 +43,7 @@
       }
       .title {
         color: #666;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         max-width: 20em;
         word-break: break-word;
       }
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 0290cb1..48fcfd7 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
@@ -78,7 +78,7 @@
       .header-title {
         flex: 1;
         font-size: 1.2em;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .prefsButton {
         float: right;
@@ -173,7 +173,7 @@
         display: initial;
       }
       .patchInfo-header,
-      gr-file-list {
+      .fileList {
         padding: .5em calc(var(--default-horizontal-margin) / 2);
       }
       .patchInfo-header {
@@ -252,6 +252,30 @@
       .editLoaded .showOnEdit {
         display: initial;
       }
+      .fileList-header {
+        display: flex;
+        font-weight: bold;
+        justify-content: space-between;
+        margin-bottom: .5em;
+      }
+      .rightControls {
+        display: flex;
+        flex-wrap: wrap;
+        font-weight: normal;
+        justify-content: flex-end;
+      }
+      .separator {
+        margin: 0 .25em;
+      }
+      .expandInline {
+        padding-right: .25em;
+      }
+      .patchSetSelect {
+        max-width: 8em;
+      }
+      .scrollable {
+        overflow: auto;
+      }
       @media screen and (min-width: 80em) {
         .commitMessage {
           max-width: var(--commit-message-max-width, 100ch);
@@ -323,16 +347,19 @@
           flex: initial;
           margin-right: 0;
         }
-        .scrollable {
-          overflow: auto;
+        /* Change actions are the only thing thant need to remain visible due
+        to the fact that they may have the currently visible overlay open. */
+        #mainContent.overlayOpen .hideOnMobileOverlay {
+          display: none;
         }
       }
     </style>
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
     <div
+        id="mainContent"
         class$="container [[_computeEditLoadedClass(_editLoaded)]]"
         hidden$="{{_loading}}">
-      <div class$="[[_computeHeaderClass(_change)]]">
+      <div class$="hideOnMobileOverlay [[_computeHeaderClass(_change)]]">
         <span class="header-title">
           <gr-change-star
               id="changeStar"
@@ -360,7 +387,7 @@
         </span>
       </div>
       <section class="changeInfo">
-        <div class="changeInfo-column changeMetadata">
+        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
           <gr-change-metadata
               change="{{_change}}"
               commit-info="[[_commitInfo]]"
@@ -394,7 +421,7 @@
                 on-download-tap="_handleDownloadTap"></gr-change-actions>
           </div>
           <hr class="mobile">
-          <div id="commitAndRelated">
+          <div id="commitAndRelated" class="hideOnMobileOverlay">
             <div class="commitContainer">
               <div
                   id="commitMessage"
@@ -459,7 +486,7 @@
           </div>
         </div>
       </section>
-      <section class$="patchInfo [[_computePatchInfoClass(_patchRange.patchNum,
+      <section class$="patchInfo hideOnMobileOverlay [[_computePatchInfoClass(_patchRange.patchNum,
           _allPatchSets)]]">
         <div class="patchInfo-header">
           <div class="patchInfo-header-wrapper">
@@ -521,22 +548,79 @@
             </span>
           </div>
         </div>
-        <gr-file-list id="fileList"
-            diff-prefs="{{_diffPrefs}}"
-            change="[[_change]]"
-            change-num="[[_changeNum]]"
-            patch-range="{{_patchRange}}"
-            comments="[[_comments]]"
-            drafts="[[_diffDrafts]]"
-            revisions="[[_sortedRevisions]]"
-            project-config="[[_projectConfig]]"
-            selected-index="{{viewState.selectedFileIndex}}"
-            diff-view-mode="{{viewState.diffMode}}"
-            edit-loaded="[[_editLoaded]]"
-            num-files-shown="{{_numFilesShown}}"
-            file-list-increment="{{_numFilesShown}}"></gr-file-list>
+        <div class="fileList">
+          <div class="fileList-header">
+            <div>Files</div>
+            <div class="rightControls">
+              <template is="dom-if"
+                  if="[[_fileListActionsVisible(_shownFileCount, _maxFilesForBulkActions)]]">
+                <gr-button
+                    id="expandBtn"
+                    link
+                    on-tap="_expandAllDiffs">Show diffs</gr-button>
+                <span class="separator">/</span>
+                <gr-button
+                    id="collapseBtn"
+                    link
+                    on-tap="_collapseAllDiffs">Hide diffs</gr-button>
+              </template>
+              <template is="dom-if"
+                  if="[[!_fileListActionsVisible(_shownFileCount, _maxFilesForBulkActions)]]">
+                <div class="warning">
+                  Bulk actions disabled because there are too many files.
+                </div>
+              </template>
+              <span class="separator">/</span>
+              <gr-select
+                  id="modeSelect"
+                  bind-value="{{viewState.diffMode}}">
+                <select>
+                  <option value="SIDE_BY_SIDE">Side By Side</option>
+                  <option value="UNIFIED_DIFF">Unified</option>
+                </select>
+              </gr-select>
+              <span class="separator">/</span>
+              <label>
+                Diff against
+                <gr-select id="patchChange" bind-value="{{_diffAgainst}}"
+                    class="patchSetSelect" on-change="_handleBasePatchChange">
+                  <select>
+                    <option value="PARENT">Base</option>
+                    <template
+                        is="dom-repeat"
+                        items="[[_allPatchSets]]"
+                        as="patchNum">
+                      <option
+                          disabled$="[[_computeBasePatchDisabled(patchNum.num, _patchRange.patchNum, _sortedRevisions)]]"
+                          value$="[[patchNum.num]]">
+                        [[patchNum.num]]
+                        [[patchNum.desc]]
+                      </option>
+                    </template>
+                  </select>
+                </gr-select>
+              </label>
+            </div>
+          </div>
+          <gr-file-list id="fileList"
+              diff-prefs="{{_diffPrefs}}"
+              change="[[_change]]"
+              change-num="[[_changeNum]]"
+              patch-range="{{_patchRange}}"
+              comments="[[_comments]]"
+              drafts="[[_diffDrafts]]"
+              revisions="[[_sortedRevisions]]"
+              project-config="[[_projectConfig]]"
+              selected-index="{{viewState.selectedFileIndex}}"
+              diff-view-mode="[[viewState.diffMode]]"
+              edit-loaded="[[_editLoaded]]"
+              num-files-shown="{{_numFilesShown}}"
+              file-list-increment="{{_numFilesShown}}"
+              on-files-shown-changed="_setShownFiles"></gr-file-list>
+        </div>
       </section>
       <gr-messages-list id="messageList"
+          class="hideOnMobileOverlay"
           change-num="[[_changeNum]]"
           messages="[[_change.messages]]"
           reviewer-updates="[[_change.reviewer_updates]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index edf7dd3..b60453b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -123,6 +123,7 @@
         computed: '_computeHideEditCommitMessage(_loggedIn, ' +
             '_editingCommitMessage, _change)',
       },
+      _diffAgainst: String,
       /** @type {?string} */
       _latestCommitMessage: {
         type: String,
@@ -134,6 +135,13 @@
         computed:
           '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
       },
+      // Caps the number of files that can be shown and have the 'show diffs' /
+      // 'hide diffs' buttons still be functional.
+      _maxFilesForBulkActions: {
+        type: Number,
+        readOnly: true,
+        value: 225,
+      },
         /** @type {?} */
       _patchRange: {
         type: Object,
@@ -162,6 +170,7 @@
         computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
       },
       _selectedPatchSet: String,
+      _shownFileCount: Number,
       _initialLoadComplete: {
         type: Boolean,
         value: false,
@@ -204,6 +213,13 @@
 
     listeners: {
       'topic-changed': '_handleTopicChanged',
+      // When an overlay is opened in a mobile viewport, the overlay has a full
+      // screen view. When it has a full screen view, we do not want the
+      // background to be scrollable. This will eliminate background scroll by
+      // hiding most of the contents on the screen upon opening, and showing
+      // again upon closing.
+      'fullscreen-overlay-opened': '_handleHideBackgroundContent',
+      'fullscreen-overlay-closed': '_handleShowBackgroundContent',
     },
     observers: [
       '_labelsChanged(_change.labels.*)',
@@ -234,6 +250,7 @@
             this._account = acct;
           });
         }
+        this._setDiffViewMode();
       });
 
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
@@ -257,6 +274,20 @@
       }
     },
 
+    _setDiffViewMode() {
+      if (!this.viewState.diffViewMode) {
+        return this.$.restAPI.getPreferences().then( prefs => {
+          if (!this.viewState.diffMode) {
+            this.set('viewState.diffMode', prefs.default_diff_view);
+          }
+        }).then(() => {
+          if (!this.viewState.diffMode) {
+            this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+          }
+        });
+      }
+    },
+
     _updateSortedRevisions(revisionsRecord) {
       const revisions = revisionsRecord.base;
       this._sortedRevisions = this.sortRevisions(Object.values(revisions));
@@ -378,8 +409,12 @@
       this._diffDrafts = diffDrafts;
     },
 
+    _handleBasePatchChange(e) {
+      this._changePatchNum(this._selectedPatchSet, e.target.value, true);
+    },
+
     _handlePatchChange(e) {
-      this._changePatchNum(e.target.value, true);
+      this._changePatchNum(e.target.value, this._diffAgainst, true);
     },
 
     _handleReplyTap(e) {
@@ -420,6 +455,14 @@
       }
     },
 
+    _handleHideBackgroundContent() {
+      this.$.mainContent.classList.add('overlayOpen');
+    },
+
+    _handleShowBackgroundContent() {
+      this.$.mainContent.classList.remove('overlayOpen');
+    },
+
     _handleReplySent(e) {
       this.$.replyOverlay.close();
       this._reload();
@@ -447,16 +490,28 @@
       }, 150);
     },
 
+    _setShownFiles(e) {
+      this._shownFileCount = e.detail.length;
+    },
+
+    _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
+      return shownFileCount <= maxFilesForBulkActions;
+    },
+
+    _expandAllDiffs() {
+      this.$.fileList.expandAllDiffs();
+    },
+
+    _collapseAllDiffs() {
+      this.$.fileList.collapseAllDiffs();
+    },
+
     _paramsChanged(value) {
       if (value.view !== Gerrit.Nav.View.CHANGE) {
         this._initialLoadComplete = false;
         return;
       }
 
-      // If the patch changed, and was not set to undefined/undefined, we need
-      // not reload all resources -- only the commit info and the file list.
-      // If the patch range was set to undefined/undefined, the user is looking
-      // to refresh the whole view.
       const patchChanged = this._patchRange &&
           (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
           (this._patchRange.patchNum !== value.patchNum ||
@@ -466,24 +521,28 @@
         this._initialLoadComplete = false;
       }
 
-      const patchNum = value.patchNum ||
-          this.computeLatestPatchNum(this._allPatchSets);
-
-      const basePatchNum = value.basePatchNum || 'PARENT';
-
-      this._patchRange = {patchNum, basePatchNum};
+      const patchRange = {
+        patchNum: value.patchNum,
+        basePatchNum: value.basePatchNum || 'PARENT',
+      };
+      this.$.fileList.collapseAllDiffs();
 
       if (this._initialLoadComplete && patchChanged) {
+        if (patchRange.patchNum == null) {
+          patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
+        }
+        this._patchRange = patchRange;
         this._reloadPatchNumDependentResources().then(() => {
           this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
             change: this._change,
-            patchNum,
+            patchNum: patchRange.patchNum,
           });
         });
         return;
       }
 
       this._changeNum = value.changeNum;
+      this._patchRange = patchRange;
       this.$.relatedChanges.clear();
 
       this._reload().then(() => {
@@ -615,12 +674,13 @@
 
     /**
      * Change active patch to the provided patch num.
-     * @param {number|string} patchNum the patchn number to be viewed.
+     * @param {number|string} basePatchNum the base patch to be viewed.
+     * @param {number|string} patchNum the patch number to be viewed.
      * @param {boolean} opt_forceParams When set to true, the resulting URL will
      *     always include the patch range, even if the requested patchNum is
      *     known to be the latest.
      */
-    _changePatchNum(patchNum, opt_forceParams) {
+    _changePatchNum(patchNum, basePatchNum, opt_forceParams) {
       if (!opt_forceParams) {
         let currentPatchNum;
         if (this._change.current_revision) {
@@ -630,13 +690,13 @@
           currentPatchNum = this.computeLatestPatchNum(this._allPatchSets);
         }
         if (this.patchNumEquals(patchNum, currentPatchNum) &&
-            this._patchRange.basePatchNum === 'PARENT') {
+            basePatchNum === 'PARENT') {
           Gerrit.Nav.navigateToChange(this._change);
           return;
         }
       }
       Gerrit.Nav.navigateToChange(this._change, patchNum,
-          this._patchRange.basePatchNum);
+          basePatchNum);
     },
 
     _computeChangeUrl(change) {
@@ -719,6 +779,11 @@
           this.findSortedIndex(basePatchNum, this._sortedRevisions);
     },
 
+    _computeBasePatchDisabled(patchNum, currentPatchNum) {
+      return this.findSortedIndex(patchNum, this._sortedRevisions) >=
+          this.findSortedIndex(currentPatchNum, this._sortedRevisions);
+    },
+
     _computeLabelNames(labels) {
       return Object.keys(labels).sort();
     },
@@ -1100,6 +1165,7 @@
 
     _updateSelected() {
       this._selectedPatchSet = this._patchRange.patchNum;
+      this._diffAgainst = this._patchRange.basePatchNum;
     },
 
     _computePatchSetDescription(change, patchNum) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 9bf7a7d..5389f1c6 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -33,6 +33,12 @@
   </template>
 </test-fixture>
 
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-change-view tests', () => {
     let element;
@@ -46,6 +52,7 @@
       stub('gr-rest-api-interface', {
         getConfig() { return Promise.resolve({test: 'config'}); },
         getAccount() { return Promise.resolve(null); },
+        _fetchSharedCacheURL() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
@@ -121,6 +128,49 @@
         });
       });
 
+      test('fullscreen-overlay-opened hides content', () => {
+        element._loggedIn = true;
+        element._loading = false;
+        element._change = {
+          owner: {_account_id: 1},
+          labels: {},
+          actions: {
+            abandon: {
+              enabled: true,
+              label: 'Abandon',
+              method: 'POST',
+              title: 'Abandon',
+            },
+          },
+        };
+        sandbox.spy(element, '_handleHideBackgroundContent');
+        element.$.replyDialog.fire('fullscreen-overlay-opened');
+        assert.isTrue(element._handleHideBackgroundContent.called);
+        assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+        assert.equal(getComputedStyle(element.$.actions).display, 'block');
+      });
+
+      test('fullscreen-overlay-closed shows content', () => {
+        element._loggedIn = true;
+        element._loading = false;
+        element._change = {
+          owner: {_account_id: 1},
+          labels: {},
+          actions: {
+            abandon: {
+              enabled: true,
+              label: 'Abandon',
+              method: 'POST',
+              title: 'Abandon',
+            },
+          },
+        };
+        sandbox.spy(element, '_handleShowBackgroundContent');
+        element.$.replyDialog.fire('fullscreen-overlay-closed');
+        assert.isTrue(element._handleShowBackgroundContent.called);
+        assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+      });
+
       test('X should expand all messages', () => {
         const handleExpand =
             sandbox.stub(element.$.messageList, 'handleExpandCollapse');
@@ -622,6 +672,132 @@
       element.fire('change', {}, {node: selectEl.nativeSelect});
     });
 
+    test('diffMode defaults to side by side without preferences', done => {
+      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+          Promise.resolve({}));
+      // No user prefs or diff view mode set.
+
+      element._setDiffViewMode().then(() => {
+        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+        done();
+      });
+    });
+
+    test('diffMode defaults to preference when not already set', done => {
+      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+          Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+      element._setDiffViewMode().then(() => {
+        assert.equal(element.viewState.diffMode, 'UNIFIED');
+        done();
+      });
+    });
+
+    test('existing diffMode overrides preference', done => {
+      element.viewState.diffMode = 'SIDE_BY_SIDE';
+      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+          Promise.resolve({default_diff_view: 'UNIFIED'}));
+      element._setDiffViewMode().then(() => {
+        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+        done();
+      });
+    });
+
+    test('diff against dropdown', done => {
+      element._changeNum = '42';
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+      element._change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+          rev3: {_number: 'edit', basePatchNum: 2},
+          rev4: {_number: 3},
+        },
+        status: 'NEW',
+        labels: {},
+      };
+
+      flush(() => {
+        const selectEl = element.$.patchChange;
+        assert.equal(selectEl.nativeSelect.value, 'PARENT');
+        assert.isTrue(element.$$('#patchChange option[value="3"]')
+            .hasAttribute('disabled'));
+        selectEl.addEventListener('change', () => {
+          assert.equal(selectEl.nativeSelect.value, 'edit');
+          assert(navigateToChangeStub.lastCall.calledWithExactly(
+              element._change, '3', 'edit'),
+              'Should navigate to /c/42/edit..3');
+          done();
+        });
+        selectEl.nativeSelect.value = 'edit';
+        element.fire('change', {}, {node: selectEl.nativeSelect});
+      });
+    });
+
+    test('expandAllDiffs called when expand button clicked', () => {
+      element._shownFileCount = 1;
+      flushAsynchronousOperations();
+      sandbox.stub(element.$.fileList, 'expandAllDiffs');
+      MockInteractions.tap(Polymer.dom(element.root).querySelector(
+          '#expandBtn'));
+      assert.isTrue(element.$.fileList.expandAllDiffs.called);
+    });
+
+    test('collapseAllDiffs called when expand button clicked', () => {
+      element._shownFileCount = 1;
+      flushAsynchronousOperations();
+      sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+      MockInteractions.tap(Polymer.dom(element.root).querySelector(
+          '#collapseBtn'));
+      assert.isTrue(element.$.fileList.collapseAllDiffs.called);
+    });
+
+    test('show/hide diffs disabled for large amounts of files', done => {
+      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+      element._files = [];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element._shownFileCount = 1;
+      flush(() => {
+        assert.isTrue(computeSpy.lastCall.returnValue);
+        _.times(element._maxFilesForBulkActions + 1, () => {
+          element._shownFileCount = element._shownFileCount + 1;
+        });
+        assert.isFalse(computeSpy.lastCall.returnValue);
+        done();
+      });
+    });
+
+    test('diff mode selector initializes from preferences', () => {
+      let resolvePrefs;
+      const prefsPromise = new Promise(resolve => {
+        resolvePrefs = resolve;
+      });
+      sandbox.stub(element.$.restAPI, 'getPreferences').returns(prefsPromise);
+
+      // Attach a new gr-change-view so we can intercept the preferences fetch.
+      const view = document.createElement('gr-change-view');
+      const select = view.$.modeSelect;
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({default_diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
+      document.getElementById('blank').restore();
+    });
+
     test('don’t reload entire page when patchRange changes', () => {
       const reloadStub = sandbox.stub(element, '_reload',
           () => { return Promise.resolve(); });
@@ -629,6 +805,7 @@
           '_reloadPatchNumDependentResources',
           () => { return Promise.resolve(); });
       const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
 
       const value = {
         view: Gerrit.Nav.View.CHANGE,
@@ -646,26 +823,22 @@
       assert.isFalse(reloadStub.calledTwice);
       assert.isTrue(reloadPatchDependentStub.calledOnce);
       assert.isTrue(relatedClearSpy.calledOnce);
+      assert.isTrue(collapseStub.calledTwice);
     });
 
     test('reload entire page when patchRange doesnt change', () => {
-      const mockPatchRange = {patchNum: '1337', basePatchNum: 'PARENT'};
       const reloadStub = sandbox.stub(element, '_reload',
           () => { return Promise.resolve(); });
-      element._patchRange = {};
-      sandbox.stub(element, 'computeLatestPatchNum').returns('1337');
+      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
       const value = {
         view: Gerrit.Nav.View.CHANGE,
       };
       element._paramsChanged(value);
       assert.isTrue(reloadStub.calledOnce);
-      assert.deepEqual(element._patchRange, mockPatchRange);
-
       element._initialLoadComplete = true;
-      element._patchRange = {};
       element._paramsChanged(value);
       assert.isTrue(reloadStub.calledTwice);
-      assert.deepEqual(element._patchRange, mockPatchRange);
+      assert.isTrue(collapseStub.calledTwice);
     });
 
     test('include base patch when not parent', () => {
@@ -686,13 +859,13 @@
         labels: {},
       };
 
-      element._changePatchNum(13);
+      element._changePatchNum(13, 2);
       assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
-          element._change, 13, '2'));
+          element._change, 13, 2));
 
       element._patchRange.basePatchNum = 'PARENT';
 
-      element._changePatchNum(3);
+      element._changePatchNum(3, 'PARENT');
       assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
           element._change, 3, 'PARENT'));
     });
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index 2b7b0fd..705cf52 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -36,7 +36,7 @@
       }
       .file {
         border-top: 1px solid #ddd;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         margin: 10px 0 3px;
         padding: 10px 0 5px;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 1d07cfe..fdd9b26 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -77,10 +77,15 @@
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a id="download" href$="[[_computeDownloadLink(change, patchNum)]]">
+          <a
+              id="download"
+              href$="[[_computeDownloadLink(change, patchNum)]]"
+              download>
             [[_computeDownloadFilename(change, patchNum)]]
           </a>
-          <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
+          <a
+              href$="[[_computeZipDownloadLink(change, patchNum)]]"
+              download>
             [[_computeZipDownloadFilename(change, patchNum)]]
           </a>
         </div>
@@ -89,7 +94,9 @@
         <label>Archive</label>
         <div id="archives" class="archives">
           <template is="dom-repeat" items="[[config.archives]]" as="format">
-            <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
+            <a
+                href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
+                download>
               [[format]]
             </a>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index b52363b..f6e1748 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -115,31 +115,39 @@
     let sandbox;
 
     setup(() => {
-      element = fixture('basic');
       sandbox = sinon.sandbox.create();
+
+      element = fixture('basic');
+      element.patchNum = '1';
+      element.config = {
+        schemes: {
+          'anonymous http': {},
+          'http': {},
+          'repo': {},
+          'ssh': {},
+        },
+        archives: ['tgz', 'tar', 'tbz2', 'txz'],
+      };
+
+      flushAsynchronousOperations();
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
+    test('anchors use download attribute', () => {
+      const anchors = Polymer.dom(element.root).querySelectorAll('a');
+      assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
+    });
+
     suite('gr-download-dialog tests with no fetch options', () => {
       setup(() => {
         element.change = getChangeObjectNoFetch();
-        element.patchNum = '1';
-        element.config = {
-          schemes: {
-            'anonymous http': {},
-            'http': {},
-            'repo': {},
-            'ssh': {},
-          },
-          archives: ['tgz', 'tar', 'tbz2', 'txz'],
-        };
+        flushAsynchronousOperations();
       });
 
       test('focuses on first download link if no copy links', () => {
-        flushAsynchronousOperations();
         const focusStub = sandbox.stub(element.$.download, 'focus');
         element.focus();
         assert.isTrue(focusStub.called);
@@ -150,20 +158,10 @@
     suite('gr-download-dialog with fetch options', () => {
       setup(() => {
         element.change = getChangeObject();
-        element.patchNum = '1';
-        element.config = {
-          schemes: {
-            'anonymous http': {},
-            'http': {},
-            'repo': {},
-            'ssh': {},
-          },
-          archives: ['tgz', 'tar', 'tbz2', 'txz'],
-        };
+        flushAsynchronousOperations();
       });
 
       test('focuses on first copy link', () => {
-        flushAsynchronousOperations();
         const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
         element.focus();
         flushAsynchronousOperations();
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 b218f02..95bfc70 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
@@ -44,21 +44,6 @@
       :host(.loading) .row {
         opacity: .5;
       };
-      header {
-        display: flex;
-        font-weight: bold;
-        justify-content: space-between;
-        margin-bottom: .5em;
-      }
-      .rightControls {
-        display: flex;
-        flex-wrap: wrap;
-        font-weight: normal;
-        justify-content: flex-end;
-      }
-      .separator {
-        margin: 0 .25em;
-      }
       .reviewed,
       .status {
         align-items: center;
@@ -119,7 +104,7 @@
       }
       .drafts {
         color: #C62828;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .show-hide {
         margin-left: .4em;
@@ -132,9 +117,6 @@
         padding-right: 2.6em;
         text-align: right;
       }
-      .expandInline {
-        padding-right: .25em;
-      }
       .warning {
         color: #666;
       }
@@ -155,9 +137,6 @@
         margin: .25em 0 1em;
         overflow-x: auto;
       }
-      .patchSetSelect {
-        max-width: 8em;
-      }
       .truncatedFileName {
         display: none;
       }
@@ -201,53 +180,6 @@
         }
       }
     </style>
-    <header>
-      <div>Files</div>
-      <div class="rightControls">
-        <template is="dom-if"
-            if="[[_fileListActionsVisible(_shownFiles.*, _maxFilesForBulkActions)]]">
-          <gr-button link on-tap="_expandAllDiffs">Show diffs</gr-button>
-          <span class="separator">/</span>
-          <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
-        </template>
-        <template is="dom-if"
-            if="[[!_fileListActionsVisible(_shownFiles.*, _maxFilesForBulkActions)]]">
-          <div class="warning">
-            Bulk actions disabled because there are too many files.
-          </div>
-        </template>
-        <span class="separator">/</span>
-        <gr-select
-            id="modeSelect"
-            bind-value="{{diffViewMode}}">
-          <select>
-            <option value="SIDE_BY_SIDE">Side By Side</option>
-            <option value="UNIFIED_DIFF">Unified</option>
-          </select>
-        </gr-select>
-        <span class="separator">/</span>
-        <label>
-          Diff against
-          <gr-select id="patchChange" bind-value="{{_diffAgainst}}"
-              class="patchSetSelect" on-change="_handlePatchChange">
-            <select>
-              <option value="PARENT">Base</option>
-              <template
-                  is="dom-repeat"
-                  items="[[computeAllPatchSets(change)]]"
-                  as="patchNum">
-                <option
-                    disabled$="[[_computePatchSetDisabled(patchNum.num, patchRange.patchNum, revisions)]]"
-                    value$="[[patchNum.num]]">
-                  [[patchNum.num]]
-                  [[patchNum.desc]]
-                </option>
-              </template>
-            </select>
-          </gr-select>
-        </label>
-      </div>
-    </header>
     <div
         id="container"
         class$="[[_computeContainerClass(editLoaded)]]"
@@ -351,7 +283,7 @@
               project-config="[[projectConfig]]"
               on-line-selected="_onLineSelected"
               no-render-on-prefs-change
-              view-mode="[[_getDiffViewMode(diffViewMode, _userPrefs)]]"></gr-diff>
+              view-mode="[[diffViewMode]]"></gr-diff>
         </template>
       </template>
     </div>
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 7164a16..100bdee 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
@@ -36,10 +36,7 @@
 
     properties: {
       /** @type {?} */
-      patchRange: {
-        type: Object,
-        observer: '_updateSelected',
-      },
+      patchRange: Object,
       patchNum: String,
       changeNum: String,
       comments: Object,
@@ -76,7 +73,6 @@
         type: Array,
         value() { return []; },
       },
-      _diffAgainst: String,
       diffPrefs: {
         type: Object,
         notify: true,
@@ -108,13 +104,6 @@
         type: Array,
         computed: '_computeFilesShown(numFilesShown, _files.*)',
       },
-      // Caps the number of files that can be shown and have the 'show diffs' /
-      // 'hide diffs' buttons still be functional.
-      _maxFilesForBulkActions: {
-        type: Number,
-        readOnly: true,
-        value: 225,
-      },
       _expandedFilePaths: {
         type: Array,
         value() { return []; },
@@ -162,7 +151,7 @@
 
       this._loading = true;
 
-      this._collapseAllDiffs();
+      this.collapseAllDiffs();
       const promises = [];
 
       promises.push(this._getFiles().then(files => {
@@ -188,9 +177,6 @@
 
       promises.push(this._getPreferences().then(prefs => {
         this._userPrefs = prefs;
-        if (!this.diffViewMode) {
-          this.set('diffViewMode', prefs.default_diff_view);
-        }
       }));
 
       return Promise.all(promises).then(() => {
@@ -239,11 +225,6 @@
       return this.$.restAPI.getPreferences();
     },
 
-    _computePatchSetDisabled(patchNum, currentPatchNum) {
-      return this.findSortedIndex(patchNum, this.revisions) >=
-          this.findSortedIndex(currentPatchNum, this.revisions);
-    },
-
     _togglePathExpanded(path) {
       // Is the path in the list of expanded diffs? IF so remove it, otherwise
       // add it to the list.
@@ -259,14 +240,6 @@
       this._togglePathExpanded(this._files[index].__path);
     },
 
-    _handlePatchChange(e) {
-      const patchRange = Object.assign({}, this.patchRange);
-      patchRange.basePatchNum = Polymer.dom(e).rootTarget.value;
-
-      Gerrit.Nav.navigateToChange(this.change, patchRange.patchNum,
-          patchRange.basePatchNum);
-    },
-
     _updateDiffPreferences() {
       if (!this.diffs.length) { return; }
       // Re-render all expanded diffs sequentially.
@@ -287,7 +260,7 @@
       }
     },
 
-    _expandAllDiffs() {
+    expandAllDiffs() {
       this._showInlineDiffs = true;
 
       // Find the list of paths that are in the file list, but not in the
@@ -304,7 +277,7 @@
       this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
     },
 
-    _collapseAllDiffs() {
+    collapseAllDiffs() {
       this._showInlineDiffs = false;
       this._expandedFilePaths = [];
       this.$.diffCursor.handleDiffUpdate();
@@ -640,9 +613,9 @@
 
     _toggleInlineDiffs() {
       if (this._showInlineDiffs) {
-        this._collapseAllDiffs();
+        this.collapseAllDiffs();
       } else {
-        this._expandAllDiffs();
+        this.expandAllDiffs();
       }
     },
 
@@ -751,7 +724,9 @@
     },
 
     _computeFilesShown(numFilesShown, files) {
-      return files.base.slice(0, numFilesShown);
+      const filesShown = files.base.slice(0, numFilesShown);
+      this.fire('files-shown-changed', {length: filesShown.length});
+      return filesShown;
     },
 
     _setReviewedFiles(shownFiles, files, reviewedRecord, loggedIn) {
@@ -816,35 +791,6 @@
       this.numFilesShown = this._files.length;
     },
 
-    _updateSelected(patchRange) {
-      this._diffAgainst = patchRange.basePatchNum;
-    },
-
-    /**
-     * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
-     * the current state.
-     *
-     * The expected behavior is to use the mode specified in the user's
-     * preferences unless they have manually chosen the alternative view.
-     *
-     * Use side-by-side if there is no view mode or preferences.
-     *
-     * @return {string}
-     */
-    _getDiffViewMode(diffViewMode, userPrefs) {
-      if (diffViewMode) {
-        return diffViewMode;
-      } else if (userPrefs) {
-        return this.diffViewMode = userPrefs.default_diff_view;
-      }
-      return 'SIDE_BY_SIDE';
-    },
-
-    _fileListActionsVisible(shownFilesRecord,
-        maxFilesForBulkActions) {
-      return shownFilesRecord.base.length <= maxFilesForBulkActions;
-    },
-
     _computePatchSetDescription(revisions, patchNum) {
       const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
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 ff62845..4b407c4 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
@@ -35,12 +35,6 @@
   </template>
 </test-fixture>
 
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
 <script>
   suite('gr-file-list tests', () => {
     let element;
@@ -653,44 +647,6 @@
       }
     });
 
-    test('diff against dropdown', done => {
-      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-          rev3: {_number: 'edit', basePatchNum: 2},
-          rev4: {_number: 3},
-        },
-      };
-      element.revisions = [
-        {_number: 1},
-        {_number: 2},
-        {_number: 'edit', basePatchNum: 2},
-        {_number: 3},
-      ];
-
-      flush(() => {
-        const selectEl = element.$.patchChange;
-        assert.equal(selectEl.nativeSelect.value, 'PARENT');
-        assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
-        selectEl.addEventListener('change', () => {
-          assert.equal(selectEl.nativeSelect.value, 'edit');
-          assert(navStub.lastCall.calledWithExactly(element.change, '3', 'edit'),
-              'Should navigate to /c/42/edit..3');
-          navStub.restore();
-          done();
-        });
-        selectEl.nativeSelect.value = 'edit';
-        element.fire('change', {}, {node: selectEl.nativeSelect});
-      });
-    });
-
     test('checkbox shows/hides diff inline', () => {
       element._files = [
         {__path: 'myfile.txt'},
@@ -737,57 +693,11 @@
       flushAsynchronousOperations();
       const diffDisplay = element.diffs[0];
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-      assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
-      assert.equal(diffDisplay.viewMode, 'SIDE_BY_SIDE');
       element.set('diffViewMode', 'UNIFIED_DIFF');
       assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
       assert.isTrue(element._updateDiffPreferences.called);
     });
 
-    test('diff mode selector initializes from preferences', () => {
-      let resolvePrefs;
-      const prefsPromise = new Promise(resolve => {
-        resolvePrefs = resolve;
-      });
-      sandbox.stub(element, '_getPreferences').returns(prefsPromise);
-
-      // Attach a new gr-file-list so we can intercept the preferences fetch.
-      const view = document.createElement('gr-file-list');
-      const select = view.$.modeSelect;
-      fixture('blank').appendChild(view);
-      flushAsynchronousOperations();
-
-      // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
-
-      // Receive the overriding preference.
-      resolvePrefs({default_diff_view: 'UNIFIED'});
-      flushAsynchronousOperations();
-      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
-      document.getElementById('blank').restore();
-    });
-
-    test('show/hide diffs disabled for large amounts of files', done => {
-      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-      element._files = [];
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.$.fileCursor.setCursorAtIndex(0);
-      flush(() => {
-        assert.isTrue(computeSpy.lastCall.returnValue);
-        const arr = [];
-        _.times(element._maxFilesForBulkActions + 1, () => {
-          arr.push({__path: 'myfile.txt'});
-        });
-        element._files = arr;
-        element.numFilesShown = arr.length;
-        assert.isFalse(computeSpy.lastCall.returnValue);
-        done();
-      });
-    });
 
     test('expanded attribute not set on path when not expanded', () => {
       element._files = [
@@ -796,19 +706,6 @@
       assert.isNotOk(element.$$('.expanded'));
     });
 
-    test('_getDiffViewMode', () => {
-      // No user prefs or diff view mode set.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-      // User prefs but no diff view mode set.
-      element.diffViewMode = null;
-      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
-      assert.equal(
-          element._getDiffViewMode(null, element._userPrefs), 'UNIFIED_DIFF');
-      // User prefs and diff view mode set.
-      element.diffViewMode = 'SIDE_BY_SIDE';
-      assert.equal(element._getDiffViewMode(
-          element.diffViewMode, element._userPrefs), 'SIDE_BY_SIDE');
-    });
     test('expand_inline_diffs user preference', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
@@ -859,6 +756,24 @@
       assert.notInclude(element._expandedFilePaths, path);
     });
 
+    test('collapseAllDiffs', () => {
+      sandbox.stub(element, '_renderInOrder')
+          .returns(Promise.resolve());
+      const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
+          'handleDiffUpdate');
+
+      const path = 'path/to/my/file.txt';
+      element.files = [{__path: path}];
+      element._expandedFilePaths = [path];
+      element._showInlineDiffs = true;
+
+      element.collapseAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element._expandedFilePaths.length, 0);
+      assert.isFalse(element._showInlineDiffs);
+      assert.isTrue(cursorUpdateStub.calledOnce);
+    });
+
     test('_expandedPathsChanged', done => {
       sandbox.stub(element, '_reviewFile');
       const path = 'path/to/my/file.txt';
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 ae43edf..c0e55ef 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -76,7 +76,7 @@
         width: 2.5em;
       }
       .name {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .message {
         --gr-formatted-text-prose-max-width: 80ch;
@@ -136,7 +136,7 @@
       }
       gr-account-label {
         --gr-account-label-text-style: {
-          font-weight: bold;
+          font-family: var(--font-family-bold);
         };
       }
     </style>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 8c02e65..2ebf7c7 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -68,7 +68,7 @@
       }
       .status {
         color: #666;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         margin-left: .25em;
       }
       .notCurrent {
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 04a1b16..68c7fd7 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
@@ -87,7 +87,7 @@
         margin-top: 1em;
       }
       .groupName {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .groupSize {
         font-style: italic;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index c147456..e95bd41 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -66,7 +66,7 @@
       if (switchAccountUrl) {
         const replacements = {path};
         const url = this._interpolateUrl(switchAccountUrl, replacements);
-        links.push({name: 'Switch account', url});
+        links.push({name: 'Switch account', url, external: true});
       }
       links.push({name: 'Sign out', url: '/logout'});
       return links;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index 6017cb9..1183d9c 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -83,14 +83,20 @@
       // Unparameterized switch account link.
       let links = element._getLinks('/switch-account');
       assert.equal(links.length, 3);
-      assert.deepEqual(links[1],
-          {name: 'Switch account', url: '/switch-account'});
+      assert.deepEqual(links[1], {
+        name: 'Switch account',
+        url: '/switch-account',
+        external: true,
+      });
 
       // Parameterized switch account link.
       links = element._getLinks('/switch-account${path}', '/c/123');
       assert.equal(links.length, 3);
-      assert.deepEqual(links[1],
-          {name: 'Switch account', url: '/switch-account/c/123'});
+      assert.deepEqual(links[1], {
+        name: 'Switch account',
+        url: '/switch-account/c/123',
+        external: true,
+      });
     });
 
     test('_interpolateUrl', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 3f3350e..2318657 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -48,12 +48,12 @@
         text-align: right;
       }
       .header {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         padding-top: 1em;
       }
       .key {
         display: inline-block;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         border-radius: 3px;
         background-color: #f1f2f3;
         padding: .1em .5em;
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index 04db296..09f3029 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -78,6 +78,9 @@
         flex: 1;
         justify-content: flex-end;
       }
+      .rightItems gr-endpoint-decorator:not(:empty) {
+        margin-left: 1em;
+      }
       gr-search-bar {
         flex-grow: 1;
         margin-left: .5em;
@@ -117,10 +120,11 @@
       @media screen and (max-width: 50em) {
         .bigTitle {
           font-size: 14px;
-          font-weight: bold;
+          font-family: var(--font-family-bold);
         }
         gr-search-bar,
         .browse,
+        .rightItems .hideOnMobile,
         .links > li.hideOnMobile {
           display: none;
         }
@@ -159,9 +163,11 @@
       </ul>
       <div class="rightItems">
         <gr-search-bar value="{{searchQuery}}" role="search"></gr-search-bar>
+        <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-browse-source"></gr-endpoint-decorator>
         <div class="accountContainer" id="accountContainer">
-          <a class="loginButton" href$="[[_loginURL]]"
-              on-tap="_loginTapHandler">Sign in</a>
+          <a class="loginButton" href$="[[_loginURL]]">Sign in</a>
           <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
         </div>
       </div>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index c7a3815..fe4f7cf 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -219,6 +219,12 @@
       // makes assumptions that work for the GWT UI, but not PolyGerrit,
       // so we'll just disable it altogether for now.
       delete linkObj.target;
+
+      // Becasue the "my menu" links may be arbitrary URLs, we don't know
+      // whether they correspond to any client routes. Mark all such links as
+      // external.
+      linkObj.external = true;
+
       return linkObj;
     },
 
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index e4cc7bd..3f45bbf 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -58,9 +58,9 @@
         {url: 'https://awesometown.com/#hashyhash'},
         {url: 'url', target: '_blank'},
       ].map(element._fixMyMenuItem), [
-        {url: '/q/owner:self+is:draft'},
-        {url: 'https://awesometown.com/#hashyhash'},
-        {url: 'url'},
+        {url: '/q/owner:self+is:draft', external: true},
+        {url: 'https://awesometown.com/#hashyhash', external: true},
+        {url: 'url', external: true},
       ]);
     });
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index f049c99..2de74e1 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -21,6 +21,10 @@
     AGREEMENTS: /^\/settings\/(agreements|new-agreement)/,
     REGISTER: /^\/register(\/.*)?/,
 
+    // Pattern for login and logout URLs intended to be passed-through. May
+    // include a return URL.
+    LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+
     // Pattern for a catchall route when no other pattern is matched.
     DEFAULT: /.*/,
 
@@ -42,6 +46,12 @@
     GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
     GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
 
+    // Matches /admin/create-project
+    LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
+
+    // Matches /admin/create-project
+    LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
+
     // Matches /admin/projects/<project>
     PROJECT: /^\/admin\/projects\/([^,]+)$/,
 
@@ -68,6 +78,8 @@
     TAG_LIST_FILTER_OFFSET:
         '/admin/projects/:project,tags/q/filter::filter,:offset',
 
+    PLUGINS: /^\/plugins\/(.+)$/,
+
     PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
 
     // Matches /admin/plugins[,<offset>][/].
@@ -439,7 +451,7 @@
           '_handleProjectCommandsRoute', true);
 
       this._mapRoute(RoutePattern.PROJECT_ACCESS,
-          '_handleProjectAccessRoute', true);
+          '_handleProjectAccessRoute');
 
       this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
           '_handleBranchListOffsetRoute');
@@ -459,6 +471,12 @@
       this._mapRoute(RoutePattern.TAG_LIST_FILTER,
           '_handleTagListFilterRoute');
 
+      this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
+          '_handleCreateGroupRoute', true);
+
+      this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
+          '_handleCreateProjectRoute', true);
+
       this._mapRoute(RoutePattern.PROJECT_LIST_OFFSET,
           '_handleProjectListOffsetRoute');
 
@@ -470,6 +488,8 @@
 
       this._mapRoute(RoutePattern.PROJECT, '_handleProjectRoute');
 
+      this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+
       this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
           '_handlePluginListOffsetRoute', true);
 
@@ -507,6 +527,8 @@
 
       this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
 
+      this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
+
       // Note: this route should appear last so it only catches URLs unmatched
       // by other patterns.
       this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
@@ -606,6 +628,7 @@
         adminView: 'gr-admin-group-list',
         offset: data.params[1] || 0,
         filter: null,
+        openCreateModal: data.hash === 'create',
       });
     },
 
@@ -719,16 +742,17 @@
     _handleProjectListOffsetRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-project-list',
+        adminView: 'gr-project-list',
         offset: data.params[1] || 0,
         filter: null,
+        openCreateModal: data.hash === 'create',
       });
     },
 
     _handleProjectListFilterOffsetRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-project-list',
+        adminView: 'gr-project-list',
         offset: data.params.offset,
         filter: data.params.filter,
       });
@@ -737,11 +761,23 @@
     _handleProjectListFilterRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-project-list',
+        adminView: 'gr-project-list',
         filter: data.params.filter || null,
       });
     },
 
+    _handleCreateProjectRoute(data) {
+      // Redirects the legacy route to the new route, which displays the project
+      // list with a hash 'create'.
+      this._redirect('/admin/projects#create');
+    },
+
+    _handleCreateGroupRoute(data) {
+      // Redirects the legacy route to the new route, which displays the group
+      // list with a hash 'create'.
+      this._redirect('/admin/groups#create');
+    },
+
     _handleProjectRoute(data) {
       this._setParams({
         view: Gerrit.Nav.View.ADMIN,
@@ -914,6 +950,14 @@
     },
 
     /**
+     * Handler for routes that should pass through the router and not be caught
+     * by the catchall _handleDefaultRoute handler.
+     */
+    _handlePassThroughRoute() {
+      location.reload();
+    },
+
+    /**
      * Catchall route for when no other route is matched.
      */
     _handleDefaultRoute() {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index 2253bc0..831b905 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -125,6 +125,8 @@
       const shouldRequireAutoAuth = [
         '_handleAdminPlaceholderRoute',
         '_handleAgreementsRoute',
+        '_handleCreateGroupRoute',
+        '_handleCreateProjectRoute',
         '_handleDiffEditRoute',
         '_handleGroupAuditLogRoute',
         '_handleGroupInfoRoute',
@@ -136,7 +138,6 @@
         '_handlePluginListFilterRoute',
         '_handlePluginListOffsetRoute',
         '_handlePluginListRoute',
-        '_handleProjectAccessRoute',
         '_handleProjectCommandsRoute',
         '_handleSettingsLegacyRoute',
         '_handleSettingsRoute',
@@ -153,6 +154,8 @@
         '_handleChangeLegacyRoute',
         '_handleDiffLegacyRoute',
         '_handleGroupMembersRoute',
+        '_handlePassThroughRoute',
+        '_handleProjectAccessRoute',
         '_handleProjectListFilterOffsetRoute',
         '_handleProjectListFilterRoute',
         '_handleProjectListOffsetRoute',
@@ -640,6 +643,7 @@
             adminView: 'gr-admin-group-list',
             offset: 0,
             filter: null,
+            openCreateModal: false,
           });
 
           data.params[1] = 42;
@@ -648,6 +652,16 @@
             adminView: 'gr-admin-group-list',
             offset: 42,
             filter: null,
+            openCreateModal: false,
+          });
+
+          data.hash = 'create';
+          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            offset: 42,
+            filter: null,
+            openCreateModal: true,
           });
         });
 
@@ -808,17 +822,28 @@
             const data = {params: {}};
             assertDataToParams(data, '_handleProjectListOffsetRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-admin-project-list',
+              adminView: 'gr-project-list',
               offset: 0,
               filter: null,
+              openCreateModal: false,
             });
 
             data.params[1] = 42;
             assertDataToParams(data, '_handleProjectListOffsetRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-admin-project-list',
+              adminView: 'gr-project-list',
               offset: 42,
               filter: null,
+              openCreateModal: false,
+            });
+
+            data.hash = 'create';
+            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              offset: 42,
+              filter: null,
+              openCreateModal: true,
             });
           });
 
@@ -826,7 +851,7 @@
             const data = {params: {filter: 'foo', offset: 42}};
             assertDataToParams(data, '_handleProjectListFilterOffsetRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-admin-project-list',
+              adminView: 'gr-project-list',
               offset: 42,
               filter: 'foo',
             });
@@ -836,14 +861,14 @@
             const data = {params: {}};
             assertDataToParams(data, '_handleProjectListFilterRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-admin-project-list',
+              adminView: 'gr-project-list',
               filter: null,
             });
 
             data.params.filter = 'foo';
             assertDataToParams(data, '_handleProjectListFilterRoute', {
               view: Gerrit.Nav.View.ADMIN,
-              adminView: 'gr-admin-project-list',
+              adminView: 'gr-project-list',
               filter: 'foo',
             });
           });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index 9ad0bf9..ca22942 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -69,7 +69,7 @@
       .authorName,
       .draftLabel,
       .draftTooltip {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .draftLabel,
       .draftTooltip {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index 99a7054..f9ca92e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -45,7 +45,7 @@
       }
       .header {
         border-bottom: 1px solid #ddd;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .mainContainer {
         padding: 1em 0;
@@ -69,7 +69,7 @@
         justify-content: space-between;
       }
       .beta {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         color: #888;
       }
     </style>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 440f44b..10d30e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -113,7 +113,7 @@
       }
       .dropdown-content a[selected] {
         color: #000;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         pointer-events: none;
         text-decoration: none;
       }
@@ -194,7 +194,7 @@
         .mobileNavLink {
           color: #000;
           font-size: 1.5em;
-          font-weight: bold;
+          font-family: var(--font-family-bold);
           text-decoration: none;
         }
         .mobileNavLink:not([href]) {
@@ -280,7 +280,9 @@
           </gr-patch-range-select>
           <span class="download desktop">
             <span class="separator">/</span>
-            <a class="downloadLink"
+            <a
+              class="downloadLink"
+              download
               href$="[[_computeDownloadLink(_changeNum, _patchRange, _path)]]">
               Download
             </a>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index e239bf0..73dd94a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -455,8 +455,10 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
-      assert.equal(element.$$('.downloadLink').getAttribute('href'),
+      const link = element.$$('.downloadLink');
+      assert.equal(link.getAttribute('href'),
           '/changes/42/revisions/10/patch?zip&path=glados.txt');
+      assert.isTrue(link.hasAttribute('download'));
     });
 
     test('file review status', done => {
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index defbe8a..406a4a7 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -46,6 +46,7 @@
 <link rel="import" href="./core/gr-reporting/gr-reporting.html">
 <link rel="import" href="./core/gr-router/gr-router.html">
 <link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
 <link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
 <link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
@@ -201,6 +202,7 @@
           on-close="_handleRegistrationDialogClose">
       </gr-registration-dialog>
     </gr-overlay>
+    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
     <gr-error-manager id="errorManager"></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 84aa438..4a38b85 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -145,12 +145,6 @@
       this.$.header.unfloat();
     },
 
-    _loginTapHandler(e) {
-      e.preventDefault();
-      page.show('/login/' + encodeURIComponent(
-          window.location.pathname + window.location.hash));
-    },
-
     // Argument used for binding update only.
     _computeLoggedIn(account) {
       return !!(account && Object.keys(account).length > 0);
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
index 02a2085..889333b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -14,49 +14,122 @@
 (function(window) {
   'use strict';
 
-  function GrDomHooks(plugin) {
+  function GrDomHooksManager(plugin) {
     this._plugin = plugin;
     this._hooks = {};
   }
 
-  GrDomHooks.prototype._getName = function(endpointName) {
-    return this._plugin.getPluginName() + '-autogenerated-' + endpointName;
+  GrDomHooksManager.prototype._getHookName = function(endpointName,
+      opt_moduleName) {
+    if (opt_moduleName) {
+      return endpointName + ' ' + opt_moduleName;
+    } else {
+      return this._plugin.getPluginName() + '-autogenerated-' + endpointName;
+    }
   };
 
-  GrDomHooks.prototype.getDomHook = function(endpointName) {
-    const hookName = this._getName(endpointName);
+  GrDomHooksManager.prototype.getDomHook = function(endpointName,
+      opt_moduleName) {
+    const hookName = this._getHookName(endpointName, opt_moduleName);
     if (!this._hooks[hookName]) {
-      this._hooks[hookName] = new GrDomHook(hookName);
+      this._hooks[hookName] = new GrDomHook(hookName, opt_moduleName);
     }
     return this._hooks[hookName];
   };
 
-  function GrDomHook(hookName) {
+  function GrDomHook(hookName, opt_moduleName) {
+    this._instances = [];
     this._callbacks = [];
-    // Expose to closure.
-    const callbacks = this._callbacks;
-    this._componentClass = Polymer({
+    if (opt_moduleName) {
+      this._moduleName = opt_moduleName;
+    } else {
+      this._moduleName = hookName;
+      this._createPlaceholder(hookName);
+    }
+  }
+
+  GrDomHook.prototype._createPlaceholder = function(hookName) {
+    Polymer({
       is: hookName,
       properties: {
         plugin: Object,
         content: Object,
       },
-      attached() {
-        callbacks.forEach(callback => {
-          callback(this);
-        });
-      },
     });
-  }
+  };
 
+  GrDomHook.prototype.handleInstanceDetached = function(instance) {
+    const index = this._instances.indexOf(instance);
+    if (index !== -1) {
+      this._instances.splice(index, 1);
+    }
+  };
+
+  GrDomHook.prototype.handleInstanceAttached = function(instance) {
+    this._instances.push(instance);
+    this._callbacks.forEach(callback => callback(instance));
+  };
+
+  /**
+   * Get instance of last DOM hook element attached into the endpoint.
+   * Returns a Promise, that's resolved when attachment is done.
+   * @return {!Promise<!Element>}
+   */
+  GrDomHook.prototype.getLastAttached = function() {
+    if (this._instances.length) {
+      return Promise.resolve(this._instances.slice(-1)[0]);
+    }
+    if (!this._lastAttachedPromise) {
+      let resolve;
+      const promise = new Promise(r => resolve = r);
+      this._callbacks.push(resolve);
+      this._lastAttachedPromise = promise.then(element => {
+        this._lastAttachedPromise = null;
+        const index = this._callbacks.indexOf(resolve);
+        if (index !== -1) {
+          this._callbacks.splice(index, 1);
+        }
+        return element;
+      });
+    }
+    return this._lastAttachedPromise;
+  };
+
+  /**
+   * Get all DOM hook elements.
+   */
+  GrDomHook.prototype.getAllAttached = function() {
+    return this._instances;
+  };
+
+  /**
+   * Install a new callback to invoke when a new instance of DOM hook element
+   * is attached.
+   * @param {function(Element)} callback
+   */
   GrDomHook.prototype.onAttached = function(callback) {
     this._callbacks.push(callback);
     return this;
   };
 
+  /**
+   * Name of DOM hook element that will be installed into the endpoint.
+   */
   GrDomHook.prototype.getModuleName = function() {
-    return this._componentClass.prototype.is;
+    return this._moduleName;
   };
 
-  window.GrDomHooks = GrDomHooks;
+  GrDomHook.prototype.getPublicAPI = function() {
+    const result = {};
+    const exposedMethods = [
+      'onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName',
+    ];
+    for (const p of exposedMethods) {
+      result[p] = this[p].bind(this);
+    }
+    return result;
+  };
+
+  window.GrDomHook = GrDomHook;
+  window.GrDomHooksManager = GrDomHooksManager;
 })(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
index f92f5c5..f5a7f6f 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
@@ -33,28 +33,110 @@
 
 <script>
   suite('gr-dom-hooks tests', () => {
+    const PUBLIC_METHODS =
+        ['onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName'];
+
     let instance;
     let sandbox;
+    let hook;
+    let hookInternal;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
       let plugin;
       Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
-      instance = new GrDomHooks(plugin);
+      instance = new GrDomHooksManager(plugin);
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
-    test('defines a Polymer components', () => {
-      const onAttachedSpy = sandbox.spy();
-      instance.getDomHook('foo-bar').onAttached(onAttachedSpy);
-      const hookName = Object.keys(instance._hooks).pop();
-      assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
-      const el = fixture('basic').appendChild(document.createElement(hookName));
-      assert.isTrue(onAttachedSpy.calledWithExactly(el));
+    suite('placeholder', () => {
+      setup(()=>{
+        sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
+        hookInternal = instance.getDomHook('foo-bar');
+        hook = hookInternal.getPublicAPI();
+      });
+
+      test('public hook API has only public methods', () => {
+        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+      });
+
+      test('registers placeholder class', () => {
+        assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
+            'testplugin-autogenerated-foo-bar'));
+      });
+
+      test('getModuleName()', () => {
+        const hookName = Object.keys(instance._hooks).pop();
+        assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
+        assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
+      });
+    });
+
+    suite('custom element', () => {
+      setup(() => {
+        hookInternal = instance.getDomHook('foo-bar', 'my-el');
+        hook = hookInternal.getPublicAPI();
+      });
+
+      test('public hook API has only public methods', () => {
+        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+      });
+
+      test('getModuleName()', () => {
+        const hookName = Object.keys(instance._hooks).pop();
+        assert.equal(hookName, 'foo-bar my-el');
+        assert.equal(hook.getModuleName(), 'my-el');
+      });
+
+      test('onAttached', () => {
+        const onAttachedSpy = sandbox.spy();
+        hook.onAttached(onAttachedSpy);
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+        assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+      });
+
+      test('getAllAttached', () => {
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        el1.textContent = 'one';
+        el2.textContent = 'two';
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        assert.deepEqual([el1, el2], hook.getAllAttached());
+        hookI.handleInstanceDetached(el1);
+        assert.deepEqual([el2], hook.getAllAttached());
+      });
+
+      test('getLastAttached', () => {
+        const beforeAttachedPromise = hook.getLastAttached().then(
+            el => assert.strictEqual(el1, el));
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        el1.textContent = 'one';
+        el2.textContent = 'two';
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        const afterAttachedPromise = hook.getLastAttached().then(
+            el => assert.strictEqual(el2, el));
+        return Promise.all([
+          beforeAttachedPromise,
+          afterAttachedPromise,
+        ]);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
index 49424a1..0928534 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
@@ -18,7 +18,7 @@
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-endpoint-decorator">
-  <template>
+  <template strip-whitespace>
     <content></content>
   </template>
   <script src="gr-endpoint-decorator.js"></script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
index 7e74494..bcd2378 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -19,6 +19,17 @@
 
     properties: {
       name: String,
+      /** @type {!Map} */
+      _domHooks: {
+        type: Map,
+        value() { return new Map(); },
+      },
+    },
+
+    detached() {
+      for (const [el, domHook] of this._domHooks) {
+        domHook.handleInstanceDetached(el);
+      }
     },
 
     _import(url) {
@@ -31,33 +42,48 @@
       const el = document.createElement(name);
       el.plugin = plugin;
       el.content = this.getContentChildren()[0];
-      return Polymer.dom(this.root).appendChild(el);
+      this._appendChild(el);
+      return el;
     },
 
     _initReplacement(name, plugin) {
       this.getContentChildren().forEach(node => node.remove());
       const el = document.createElement(name);
       el.plugin = plugin;
-      return Polymer.dom(this.root).appendChild(el);
+      this._appendChild(el);
+      return el;
+    },
+
+    _appendChild(el) {
+      Polymer.dom(this.root).appendChild(el);
+    },
+
+    _initModule({moduleName, plugin, type, domHook}) {
+      let el;
+      switch (type) {
+        case 'decorate':
+          el = this._initDecoration(moduleName, plugin);
+          break;
+        case 'replace':
+          el = this._initReplacement(moduleName, plugin);
+          break;
+      }
+      if (el) {
+        domHook.handleInstanceAttached(el);
+      }
+      this._domHooks.set(el, domHook);
     },
 
     ready() {
+      Gerrit._endpoints.onNewEndpoint(this.name, this._initModule.bind(this));
       Gerrit.awaitPluginsLoaded().then(() => Promise.all(
           Gerrit._endpoints.getPlugins(this.name).map(
               pluginUrl => this._import(pluginUrl)))
-      ).then(() => {
-        const modulesData = Gerrit._endpoints.getDetails(this.name);
-        for (const {moduleName, plugin, type} of modulesData) {
-          switch (type) {
-            case 'decorate':
-              this._initDecoration(moduleName, plugin);
-              break;
-            case 'replace':
-              this._initReplacement(moduleName, plugin);
-              break;
-          }
-        }
-      });
+      ).then(() =>
+        Gerrit._endpoints
+            .getDetails(this.name)
+            .forEach(this._initModule, this)
+      );
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
index 8b96dee..e7d1930 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -34,10 +34,20 @@
     let sandbox;
     let element;
     let plugin;
+    let domHookStub;
 
     setup(done => {
+      Gerrit._endpoints = new GrPluginEndpoints();
+
       sandbox = sinon.sandbox.create();
 
+      domHookStub = {
+        handleInstanceAttached: sandbox.stub(),
+        handleInstanceDetached: sandbox.stub(),
+      };
+      sandbox.stub(
+          GrDomHooksManager.prototype, 'getDomHook').returns(domHookStub);
+
       // NB: Order is important.
       Gerrit.install(p => {
         plugin = p;
@@ -45,11 +55,12 @@
         plugin.registerCustomComponent('foo', 'other-module', {replace: true});
       }, '0.1', 'http://some/plugin/url.html');
 
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
       sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
 
       element = fixture('basic');
-      sandbox.stub(element, '_initDecoration');
-      sandbox.stub(element, '_initReplacement');
+      sandbox.stub(element, '_initDecoration').returns({});
+      sandbox.stub(element, '_initReplacement').returns({});
       sandbox.stub(element, 'importHref', (url, resolve) => resolve());
 
       flush(done);
@@ -65,13 +76,39 @@
     });
 
     test('inits decoration dom hook', () => {
-      assert.isTrue(
-          element._initDecoration.calledWith('some-module', plugin));
+      assert.strictEqual(
+          element._initDecoration.lastCall.args[0], 'some-module');
+      assert.strictEqual(
+          element._initDecoration.lastCall.args[1], plugin);
     });
 
     test('inits replacement dom hook', () => {
-      assert.isTrue(
-          element._initReplacement.calledWith('other-module', plugin));
+      assert.strictEqual(
+          element._initReplacement.lastCall.args[0], 'other-module');
+      assert.strictEqual(
+          element._initReplacement.lastCall.args[1], plugin);
+    });
+
+    test('calls dom hook handleInstanceAttached', () => {
+      assert.equal(domHookStub.handleInstanceAttached.callCount, 2);
+    });
+
+    test('calls dom hook handleInstanceDetached', () => {
+      element.detached();
+      assert.equal(domHookStub.handleInstanceDetached.callCount, 2);
+    });
+
+    test('installs modules on late registration', done => {
+      domHookStub.handleInstanceAttached.reset();
+      plugin.registerCustomComponent('foo', 'noob-noob');
+      flush(() => {
+        assert.equal(domHookStub.handleInstanceAttached.callCount, 1);
+        assert.strictEqual(
+            element._initDecoration.lastCall.args[0], 'noob-noob');
+        assert.strictEqual(
+            element._initDecoration.lastCall.args[1], plugin);
+        done();
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
new file mode 100644
index 0000000..3ccb3fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
@@ -0,0 +1,28 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+
+<dom-module id="gr-plugin-popup">
+  <template>
+    <style include="shared-styles"></style>
+    <gr-overlay id="overlay" with-backdrop>
+      <content></content>
+    </gr-overlay>
+  </template>
+  <script src="gr-plugin-popup.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
new file mode 100644
index 0000000..8286eae
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+  Polymer({
+    is: 'gr-plugin-popup',
+    get opened() {
+      return this.$.overlay.opened;
+    },
+    open() {
+      return this.$.overlay.open();
+    },
+    close() {
+      this.$.overlay.close();
+    },
+  });
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
new file mode 100644
index 0000000..2dbf96d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-popup</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-plugin-popup.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-popup></gr-plugin-popup>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-plugin-popup tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      stub('gr-overlay', {
+        open: sandbox.stub().returns(Promise.resolve()),
+        close: sandbox.stub(),
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(element);
+    });
+
+    test('open uses open() from gr-overlay', () => {
+      return element.open().then(() => {
+        assert.isTrue(element.$.overlay.open.called);
+      });
+    });
+
+    test('close uses close() from gr-overlay', () => {
+      element.close();
+      assert.isTrue(element.$.overlay.close.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
new file mode 100644
index 0000000..6bf37de
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
@@ -0,0 +1,23 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="gr-plugin-popup.html">
+
+<dom-module id="gr-popup-interface">
+  <script src="gr-popup-interface.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
new file mode 100644
index 0000000..e62e882
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -0,0 +1,71 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  /**
+   * Plugin popup API.
+   * Provides method for opening and closing popups from plugin.
+   * opt_moduleName is a name of custom element that will be automatically
+   * inserted on popup opening.
+   * @param {!Object} plugin
+   * @param {opt_moduleName=} string
+   */
+  function GrPopupInterface(plugin, opt_moduleName) {
+    this.plugin = plugin;
+    this._openingPromise = null;
+    this._popup = null;
+    this._moduleName = opt_moduleName || null;
+  }
+
+  GrPopupInterface.prototype._getElement = function() {
+    return Polymer.dom(this._popup);
+  };
+
+  /**
+   * Opens the popup, inserts it into DOM over current UI.
+   * Creates the popup if not previously created. Creates popup content element,
+   * if it was provided with constructor.
+   * @returns {!Promise<!Object>}
+   */
+  GrPopupInterface.prototype.open = function() {
+    if (!this._openingPromise) {
+      this._openingPromise =
+          this.plugin.hook('plugin-overlay').getLastAttached()
+      .then(hookEl => {
+        const popup = document.createElement('gr-plugin-popup');
+        if (this._moduleName) {
+          const el = Polymer.dom(popup).appendChild(
+              document.createElement(this._moduleName));
+          el.plugin = this.plugin;
+        }
+        this._popup = Polymer.dom(hookEl).appendChild(popup);
+        Polymer.dom.flush();
+        return this._popup.open().then(() => this);
+      });
+    }
+    return this._openingPromise;
+  };
+
+  /**
+   * Hides the popup.
+   */
+  GrPopupInterface.prototype.close = function() {
+    if (!this._popup) { return; }
+    this._popup.close();
+    this._openingPromise = null;
+  };
+
+  window.GrPopupInterface = GrPopupInterface;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
new file mode 100644
index 0000000..7d9dd28
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
@@ -0,0 +1,112 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-popup-interface</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-popup-interface.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="container">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<dom-module id="gr-user-test-popup">
+  <template>
+    <div id="barfoo">some test module</div>
+  </template>
+  <script>Polymer({is: 'gr-user-test-popup'});</script>
+</dom-module>
+
+<script>
+  suite('gr-popup-interface tests', () => {
+    let container;
+    let instance;
+    let plugin;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      container = fixture('container');
+      sandbox.stub(plugin, 'hook').returns({
+        getLastAttached() {
+          return Promise.resolve(container);
+        },
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('manual', () => {
+      setup(() => {
+        instance = new GrPopupInterface(plugin);
+      });
+
+      test('open', () => {
+        return instance.open().then(api => {
+          assert.strictEqual(api, instance);
+          const manual = document.createElement('div');
+          manual.id = 'foobar';
+          manual.innerHTML = 'manual content';
+          api._getElement().appendChild(manual);
+          flushAsynchronousOperations();
+          assert.equal(
+              container.querySelector('#foobar').textContent, 'manual content');
+        });
+      });
+
+      test('close', () => {
+        return instance.open().then(api => {
+          assert.isTrue(api._getElement().node.opened);
+          api.close();
+          assert.isFalse(api._getElement().node.opened);
+        });
+      });
+    });
+
+    suite('components', () => {
+      setup(() => {
+        instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+      });
+
+      test('open', () => {
+        return instance.open().then(api => {
+          assert.isNotNull(
+              Polymer.dom(container).querySelector('gr-user-test-popup'));
+        });
+      });
+
+      test('close', () => {
+        return instance.open().then(api => {
+          assert.isTrue(api._getElement().node.opened);
+          api.close();
+          assert.isFalse(api._getElement().node.opened);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
index e91ab0a..d57b301 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
@@ -22,7 +22,7 @@
   }
 
   GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
-    this.plugin.getDomHook('header-title', {replace: true}).onAttached(
+    this.plugin.hook('header-title', {replace: true}).onAttached(
         element => {
           const customHeader =
                 document.createElement('gr-custom-plugin-header');
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index 34f0b16..1bd345e 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -36,7 +36,7 @@
       }
       header {
         border-bottom: 1px solid #ddd;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       header,
       main,
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index bf5e4e0..51fa616 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -53,7 +53,7 @@
       }
       .action {
         color: #a1c2fa;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         margin-left: 1em;
         text-decoration: none;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index 1fecbe7..2cbcfa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -32,7 +32,7 @@
         display: inline-block;
         font-family: var(--font-family);
         font-size: 12px;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         outline-width: 0;
         padding: .4em .85em;
         position: relative;
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index d4a98e4..27c0355 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -33,7 +33,7 @@
       header {
         border-bottom: 1px solid #ddd;
         flex-shrink: 0;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       main {
         display: flex;
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
index 05075bd..68c3848 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
@@ -40,11 +40,11 @@
         display: block;
       }
       label {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       li[selected] gr-button {
         color: #000;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         text-decoration: none;
       }
       .schemes {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index 8f2994d..f4813fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -61,7 +61,7 @@
         list-style: none;
       }
       ul .accountName {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       li .accountInfo,
       li .itemAction {
@@ -92,7 +92,7 @@
         padding: .85em 1em;
       }
       .bold-text {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       :host:not([down-arrow]) .downArrow { display: none; }
       :host([down-arrow]) .downArrow {
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index a66ab2f..ec8f04c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  const REL_NOOPENER = 'noopener';
+  const REL_EXTERNAL = 'external';
+
   Polymer({
     is: 'gr-dropdown',
 
@@ -83,6 +86,10 @@
       'up': '_handleUp',
     },
 
+    /**
+     * Handle the up key.
+     * @param {!Event} e
+     */
     _handleUp(e) {
       if (this.$.dropdown.opened) {
         e.preventDefault();
@@ -93,6 +100,10 @@
       }
     },
 
+    /**
+     * Handle the down key.
+     * @param {!Event} e
+     */
     _handleDown(e) {
       if (this.$.dropdown.opened) {
         e.preventDefault();
@@ -103,6 +114,10 @@
       }
     },
 
+    /**
+     * Handle the tab key.
+     * @param {!Event} e
+     */
     _handleTab(e) {
       if (this.$.dropdown.opened) {
         // Tab in a native select is a no-op. Emulate this.
@@ -111,6 +126,10 @@
       }
     },
 
+    /**
+     * Handle the enter key.
+     * @param {!Event} e
+     */
     _handleEnter(e) {
       e.preventDefault();
       e.stopPropagation();
@@ -125,6 +144,10 @@
       }
     },
 
+    /**
+     * Handle a click on the iron-dropdown element.
+     * @param {!Event} e
+     */
     _handleDropdownTap(e) {
       // async is needed so that that the click event is fired before the
       // dropdown closes (This was a bug for touch devices).
@@ -133,10 +156,17 @@
       }, 1);
     },
 
+    /**
+     * Hanlde a click on the button to open the dropdown.
+     * @param {!Event} e
+     */
     _showDropdownTapHandler(e) {
       this._open();
     },
 
+    /**
+     * Open the dropdown and initialize the cursor.
+     */
     _open() {
       this.$.dropdown.open();
       this.$.cursor.setCursorAtIndex(0);
@@ -144,19 +174,43 @@
       this.$.cursor.target.focus();
     },
 
+    /**
+     * Get the class for a top-content item based on the given boolean.
+     * @param {boolean} bold Whether the item is bold.
+     * @return {string} The class for the top-content item.
+     */
     _getClassIfBold(bold) {
       return bold ? 'bold-text' : '';
     },
 
+    /**
+     * Build a URL for the given host and path. If there is a base URL, it will
+     * be included between the host and the path.
+     * @param {!string} host
+     * @param {!string} path
+     * @return {!string} The scheme-relative URL.
+     */
     _computeURLHelper(host, path) {
       return '//' + host + this.getBaseUrl() + path;
     },
 
+    /**
+     * Build a scheme-relative URL for the current host. Will include the base
+     * URL if one is present. Note: the URL will be scheme-relative but absolute
+     * with regard to the host.
+     * @param {!string} path The path for the URL.
+     * @return {!string} The scheme-relative URL.
+     */
     _computeRelativeURL(path) {
       const host = window.location.host;
       return this._computeURLHelper(host, path);
     },
 
+    /**
+     * Compute the URL for a link object.
+     * @param {!Object} link The object describing the link.
+     * @return {!string} The URL.
+     */
     _computeLinkURL(link) {
       if (typeof link.url === 'undefined') {
         return '';
@@ -167,10 +221,24 @@
       return this._computeRelativeURL(link.url);
     },
 
+    /**
+     * Compute the value for the rel attribute of an anchor for the given link
+     * object. If the link has a target value, then the rel must be "noopener"
+     * for security reasons.
+     * @param {!Object} link The object describing the link.
+     * @return {?string} The rel value for the link.
+     */
     _computeLinkRel(link) {
-      return link.target ? 'noopener' : null;
+      // Note: noopener takes precedence over external.
+      if (link.target) { return REL_NOOPENER; }
+      if (link.external) { return REL_EXTERNAL; }
+      return null;
     },
 
+    /**
+     * Handle a click on an item of the dropdown.
+     * @param {!Event} e
+     */
     _handleItemTap(e) {
       const id = e.target.getAttribute('data-id');
       const item = this.items.find(item => item.id === id);
@@ -182,10 +250,20 @@
       }
     },
 
+    /**
+     * If a dropdown item is shown as a button, get the class for the button.
+     * @param {string} id
+     * @param {!Object} disabledIdsRecord The change record for the disabled IDs
+     *     list.
+     * @return {!string} The class for the item button.
+     */
     _computeDisabledClass(id, disabledIdsRecord) {
       return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
     },
 
+    /**
+     * Recompute the stops for the dropdown item cursor.
+     */
     _resetCursorStops() {
       Polymer.dom.flush();
       this._listElements = Polymer.dom(this.root).querySelectorAll('li');
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 8654ac8..ab31f7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -72,10 +72,17 @@
     });
 
     test('link rel', () => {
-      assert.isNull(element._computeLinkRel({url: '/test'}));
-      assert.equal(
-          element._computeLinkRel({url: '/test', target: '_blank'}),
-          'noopener');
+      let link = {url: '/test'};
+      assert.isNull(element._computeLinkRel(link));
+
+      link = {url: '/test', target: '_blank'};
+      assert.equal(element._computeLinkRel(link), 'noopener');
+
+      link = {url: '/test', external: true};
+      assert.equal(element._computeLinkRel(link), 'external');
+
+      link = {url: '/test', target: '_blank', external: true};
+      assert.equal(element._computeLinkRel(link), 'noopener');
     });
 
     test('_getClassIfBold', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index df407a9..65aa364 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -49,7 +49,7 @@
 
   GrChangeReplyInterface.prototype.addReplyTextChangedCallback =
     function(handler) {
-      this.plugin.getDomHook('reply-text').onAttached(el => {
+      this.plugin.hook('reply-text').onAttached(el => {
         if (!el.content) { return; }
         el.content.addEventListener('value-changed', e => {
           handler(e.detail.value);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index f6e2b64..53f889f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../plugins/gr-attribute-helper/gr-attribute-helper.html">
 <link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
+<link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
 <link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index ca0f372..dec2dc3d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -120,6 +120,26 @@
       });
     });
 
+    test('delete works', () => {
+      const response = {status: 204};
+      sendStub.returns(Promise.resolve(response));
+      return plugin.delete('/url', r => {
+        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('delete fails', () => {
+      sendStub.returns(Promise.resolve(
+          {status: 400, text() { return Promise.resolve('text'); }}));
+      return plugin.delete('/url', r => {
+        throw new Error('Should not resolve');
+      }).catch(err => {
+        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+        assert.equal('text', err);
+      });
+    });
+
     test('history event', done => {
       plugin.on(element.EventType.HISTORY, throwErrFn);
       plugin.on(element.EventType.HISTORY, path => {
@@ -338,5 +358,35 @@
             'http://test.com/r/plugins/testplugin/static/test.js');
       });
     });
+
+    suite('popup', () => {
+      test('popup(element) is deprecated', () => {
+        assert.throws(() => {
+          plugin.popup(document.createElement('div'));
+        });
+      });
+
+      test('popup(moduleName) creates popup with component', () => {
+        const openStub = sandbox.stub();
+        sandbox.stub(window, 'GrPopupInterface').returns({
+          open: openStub,
+        });
+        plugin.popup('some-name');
+        assert.isTrue(openStub.calledOnce);
+        assert.isTrue(GrPopupInterface.calledWith(plugin, 'some-name'));
+      });
+
+      test('deprecated.popup(element) creates popup with element', () => {
+        const el = document.createElement('div');
+        el.textContent = 'some text here';
+        const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
+        openStub.returns(Promise.resolve({
+          _getElement() {
+            return document.createElement('div');
+          }}));
+        plugin.deprecated.popup(el);
+        assert.isTrue(openStub.calledOnce);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
index 52b1fb7..1ee9eec 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -16,19 +16,32 @@
 
   function GrPluginEndpoints() {
     this._endpoints = {};
+    this._callbacks = {};
   }
 
+  GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
+    if (!this._callbacks[endpoint]) {
+      this._callbacks[endpoint] = [];
+    }
+    this._callbacks[endpoint].push(callback);
+  };
+
   GrPluginEndpoints.prototype.registerModule = function(plugin, endpoint, type,
-      moduleName) {
+      moduleName, domHook) {
     if (!this._endpoints[endpoint]) {
       this._endpoints[endpoint] = [];
     }
-    this._endpoints[endpoint].push({
+    const moduleInfo = {
       moduleName,
       plugin,
       pluginUrl: plugin._url,
       type,
-    });
+      domHook,
+    };
+    this._endpoints[endpoint].push(moduleInfo);
+    if (Gerrit._arePluginsLoaded() && this._callbacks[endpoint]) {
+      this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
+    }
   };
 
   /**
@@ -44,6 +57,7 @@
    *   plugin: Plugin,
    *   pluginUrl: String,
    *   type: EndpointType,
+   *   domHook: !Object
    * }>}
    */
   GrPluginEndpoints.prototype.getDetails = function(name, opt_options) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
index 2c1f4e9..a61cdc8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -29,16 +29,21 @@
     let instance;
     let pluginFoo;
     let pluginBar;
+    let domHook;
 
     setup(() => {
       sandbox = sinon.sandbox.create();
+      domHook = {};
       instance = new GrPluginEndpoints();
       Gerrit.install(p => { pluginFoo = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/foo.html');
-      instance.registerModule(pluginFoo, 'a-place', 'decorate', 'foo-module');
+      instance.registerModule(
+          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
       Gerrit.install(p => { pluginBar = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/bar.html');
-      instance.registerModule(pluginBar, 'a-place', 'style', 'bar-module');
+      instance.registerModule(
+          pluginBar, 'a-place', 'style', 'bar-module', domHook);
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
     });
 
     teardown(() => {
@@ -52,12 +57,14 @@
           plugin: pluginFoo,
           pluginUrl: pluginFoo._url,
           type: 'decorate',
+          domHook,
         },
         {
           moduleName: 'bar-module',
           plugin: pluginBar,
           pluginUrl: pluginBar._url,
           type: 'style',
+          domHook,
         },
       ]);
     });
@@ -69,6 +76,7 @@
           plugin: pluginBar,
           pluginUrl: pluginBar._url,
           type: 'style',
+          domHook,
         },
       ]);
     });
@@ -82,6 +90,7 @@
               plugin: pluginFoo,
               pluginUrl: pluginFoo._url,
               type: 'decorate',
+              domHook,
             },
           ]);
     });
@@ -95,5 +104,19 @@
       assert.deepEqual(
           instance.getPlugins('a-place'), [pluginFoo._url, pluginBar._url]);
     });
+
+    test('onNewEndpoint', () => {
+      const newModuleStub = sandbox.stub();
+      instance.onNewEndpoint('a-place', newModuleStub);
+      instance.registerModule(
+          pluginFoo, 'a-place', 'replace', 'zaz-module', domHook);
+      assert.deepEqual(newModuleStub.lastCall.args[0], {
+        moduleName: 'zaz-module',
+        plugin: pluginFoo,
+        pluginUrl: pluginFoo._url,
+        type: 'replace',
+        domHook,
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index d1b9417..52db873 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -55,8 +55,7 @@
   window.$wnd = window;
 
   function Plugin(opt_url) {
-    this._generatedHookNames = [];
-    this._domHooks = new GrDomHooks(this);
+    this._domHooks = new GrDomHooksManager(this);
 
     if (!opt_url) {
       console.warn('Plugin not being loaded from /plugins base path.',
@@ -77,6 +76,10 @@
       return;
     }
     this._name = pathname.split('/')[2];
+
+    this.deprecated = {
+      popup: deprecatedAPI.popup.bind(this),
+    };
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -93,11 +96,22 @@
   };
 
   Plugin.prototype.registerCustomComponent = function(
-      endpointName, moduleName, opt_options) {
+      endpointName, opt_moduleName, opt_options) {
     const type = opt_options && opt_options.replace ?
           EndpointType.REPLACE : EndpointType.DECORATE;
+    const hook = this._domHooks.getDomHook(endpointName, opt_moduleName);
+    const moduleName = opt_moduleName || hook.getModuleName();
     Gerrit._endpoints.registerModule(
-        this, endpointName, type, moduleName);
+        this, endpointName, type, moduleName, hook);
+    return hook.getPublicAPI();
+  };
+
+  /**
+   * Returns instance of DOM hook API for endpoint. Creates a placeholder
+   * element for the first call.
+   */
+  Plugin.prototype.hook = function(endpointName, opt_options) {
+    return this.registerCustomComponent(endpointName, undefined, opt_options);
   };
 
   Plugin.prototype.getServerInfo = function() {
@@ -143,6 +157,24 @@
     return this._send('POST', url, opt_callback, payload);
   },
 
+  Plugin.prototype.delete = function(url, opt_callback) {
+    return getRestAPI().send('DELETE', url, opt_callback).then(response => {
+      if (response.status !== 204) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      }
+      if (opt_callback) {
+        opt_callback(response);
+      }
+      return response;
+    });
+  },
+
   Plugin.prototype.changeActions = function() {
     return new GrChangeActionsInterface(Plugin._sharedAPIElement.getElement(
         Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
@@ -166,12 +198,22 @@
     return new GrAttributeHelper(element);
   };
 
-  Plugin.prototype.getDomHook = function(endpointName, opt_options) {
-    const hook = this._domHooks.getDomHook(endpointName);
-    const moduleName = hook.getModuleName();
-    const type = opt_options && opt_options.type || EndpointType.DECORATE;
-    Gerrit._endpoints.registerModule(this, endpointName, type, moduleName);
-    return hook;
+  Plugin.prototype.popup = function(moduleName) {
+    if (typeof moduleName !== 'string') {
+      throw new Error('deprecated, use deprecated.popup');
+    }
+    const api = new GrPopupInterface(this, moduleName);
+    return api.open();
+  };
+
+  const deprecatedAPI = {};
+  deprecatedAPI.popup = function(el) {
+    console.warn('plugin.deprecated.popup() is deprecated!');
+    if (!el) {
+      throw new Error('Popup contents not found');
+    }
+    const api = new GrPopupInterface(this);
+    api.open().then(api => api._getElement().appendChild(el));
   };
 
   const Gerrit = window.Gerrit || {};
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index 419d2f7..1b59d35 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -25,6 +25,16 @@
         background: #fff;
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
       }
+
+      @media screen and (max-width: 50em) {
+        :host {
+          height: 100%;
+          left: 0;
+          position: fixed;
+          right: 0;
+          top: 0;
+        }
+      }
     </style>
     <content></content>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index 8db8004..ebf2f02 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -16,21 +16,61 @@
 
   const AWAIT_MAX_ITERS = 10;
   const AWAIT_STEP = 5;
+  const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
 
   Polymer({
     is: 'gr-overlay',
 
+    /**
+     * Fired when a fullscreen overlay is closed
+     *
+     * @event fullscreen-overlay-closed
+     */
+
+    /**
+     * Fired when an overlay is opened in full screen mode
+     *
+     * @event fullscreen-overlay-opened
+     */
+
+    properties: {
+      _fullScreenOpen: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
     behaviors: [
       Polymer.IronOverlayBehavior,
     ],
 
+    listeners: {
+      'iron-overlay-closed': '_close',
+      'iron-overlay-cancelled': '_close',
+    },
+
     open(...args) {
       return new Promise(resolve => {
         Polymer.IronOverlayBehaviorImpl.open.apply(this, args);
+        if (this._isMobile()) {
+          this.fire('fullscreen-overlay-opened');
+          this._fullScreenOpen = true;
+        }
         this._awaitOpen(resolve);
       });
     },
 
+    _isMobile() {
+      return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+    },
+
+    _close() {
+      if (this._fullScreenOpen) {
+        this.fire('fullscreen-overlay-closed');
+        this._fullScreenOpen = false;
+      }
+    },
+
     /**
      * Override the focus stops that iron-overlay-behavior tries to find.
      */
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
new file mode 100644
index 0000000..3f427ca
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-overlay</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-overlay.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-overlay>
+      <div>content</div>
+    </gr-overlay>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-overlay tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('events are fired on fullscreen view', done => {
+      sandbox.stub(element, '_isMobile').returns(true);
+      const openHandler = sandbox.stub();
+      const closeHandler = sandbox.stub();
+      element.addEventListener('fullscreen-overlay-opened', openHandler);
+      element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+      element.open().then(() => {
+        assert.isTrue(element._isMobile.called);
+        assert.isTrue(element._fullScreenOpen);
+        assert.isTrue(openHandler.called);
+
+        element._close();
+        assert.isFalse(element._fullScreenOpen);
+        assert.isTrue(closeHandler.called);
+        done();
+      });
+    });
+
+    test('events are not fired on desktop view', done => {
+      sandbox.stub(element, '_isMobile').returns(false);
+      const openHandler = sandbox.stub();
+      const closeHandler = sandbox.stub();
+      element.addEventListener('fullscreen-overlay-opened', openHandler);
+      element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+      element.open().then(() => {
+        assert.isTrue(element._isMobile.called);
+        assert.isFalse(element._fullScreenOpen);
+        assert.isFalse(openHandler.called);
+
+        element._close();
+        assert.isFalse(element._fullScreenOpen);
+        assert.isFalse(closeHandler.called);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
index 2d48d36..21aa6cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
@@ -55,7 +55,16 @@
   };
 
   GrEtagDecorator.prototype.getCachedPayload = function(url) {
-    return this._payloadCache.get(url);
+    let payload = this._payloadCache.get(url);
+
+    if (typeof payload === 'object') {
+      // Note: For the sake of cache transparency, deep clone the response
+      // object so that cache hits are not equal object references. Some code
+      // expects every network response to deserialize to a fresh object.
+      payload = JSON.parse(JSON.stringify(payload));
+    }
+
+    return payload;
   };
 
   GrEtagDecorator.prototype._truncateCache = function() {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
index 8be2352..40e639e 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
@@ -84,7 +84,7 @@
     });
 
     test('getCachedPayload', () => {
-      const payload = {};
+      const payload = 'payload';
       etag.collect('/foo', fakeRequest('bar'), payload);
       assert.strictEqual(etag.getCachedPayload('/foo'), payload);
       etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
@@ -92,5 +92,25 @@
       etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
       assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
     });
+
+    test('getCachedPayload does not preserve object equality', () => {
+      const payload = {foo: 'bar'};
+      etag.collect('/foo', fakeRequest('bar'), payload);
+      assert.deepEqual(etag.getCachedPayload('/foo'), payload);
+      assert.notStrictEqual(etag.getCachedPayload('/foo'), payload);
+      etag.collect('/foo', fakeRequest('bar', 304), {foo: 'baz'});
+      assert.deepEqual(etag.getCachedPayload('/foo'), payload);
+      assert.notStrictEqual(etag.getCachedPayload('/foo'), payload);
+      etag.collect('/foo', fakeRequest('bar', 200), {foo: 'bar baz'});
+      assert.deepEqual(etag.getCachedPayload('/foo'), {foo: 'bar baz'});
+      assert.notStrictEqual(etag.getCachedPayload('/foo'), {foo: 'bar baz'});
+    });
+
+    test('getCachedPayload clones the response deeply', () => {
+      const payload = {foo: {bar: 'baz'}};
+      etag.collect('/foo', fakeRequest('bar'), payload);
+      assert.deepEqual(etag.getCachedPayload('/foo'), payload);
+      assert.notStrictEqual(etag.getCachedPayload('/foo').foo, payload.foo);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
index 46fc46b..080c345 100644
--- a/polygerrit-ui/app/index.html
+++ b/polygerrit-ui/app/index.html
@@ -26,6 +26,10 @@
 -->
 <link rel="preload" href="/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin>
 <link rel="preload" href="/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin>
 <link rel="stylesheet" href="/styles/fonts.css">
 <link rel="stylesheet" href="/styles/main.css">
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index be80c13..a77ad50 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -72,7 +72,7 @@
       name + "_app_sources",
       name + "_css_sources",
       name + "_top_sources",
-      "//lib/fonts:robotomono",
+      "//lib/fonts:robotofonts",
       "//lib/js:highlightjs_files",
       # we extract from the zip, but depend on the component for license checking.
       "@webcomponentsjs//:zipfile",
@@ -82,7 +82,7 @@
     cmd = " && ".join([
       "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
       "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/"  + appName + ".$$ext; done",
-      "cp $(locations //lib/fonts:robotomono) $$TMP/polygerrit_ui/fonts/",
+      "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
       "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
       "for f in $(locations "+ name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
       "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index 4728fe6..4318757 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -29,11 +29,12 @@
   --default-text-color: #000;
   --view-background-color: #fff;
   --default-horizontal-margin: 1rem;
-  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --font-family-bold: 'Roboto Medium', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   --monospace-font-family: 'Roboto Mono', Menlo, 'Lucida Console', Monaco, monospace;
   --iron-overlay-backdrop: {
     transition: none;
-  };
+  }
 }
 @media screen and (max-width: 50em) {
   :root {
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css
index d339e16..6a5da44 100644
--- a/polygerrit-ui/app/styles/fonts.css
+++ b/polygerrit-ui/app/styles/fonts.css
@@ -17,4 +17,46 @@
        url('../fonts/RobotoMono-Regular.woff2') format('woff2'),
        url('../fonts/RobotoMono-Regular.woff') format('woff');
   unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto'), local('Roboto-Regular'),
+       url('../fonts/Roboto-Regular.woff2') format('woff2'),
+       url('../fonts/Roboto-Regular.woff') format('woff');
+  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto'), local('RobotoMono-Regular'),
+       url('../fonts/Roboto-Regular.woff2') format('woff2'),
+       url('../fonts/Roboto-Regular.woff') format('woff');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto Medium';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+       url('../fonts/Roboto-Medium.woff2') format('woff2'),
+       url('../fonts/Roboto-Medium.woff') format('woff');
+  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto Medium';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+       url('../fonts/Roboto-Medium.woff2') format('woff2'),
+       url('../fonts/Roboto-Medium.woff') format('woff');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
 }
\ No newline at end of file
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index d283aac..00ea613 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -22,7 +22,7 @@
       .topHeader,
       .groupHeader {
         border-bottom: 1px solid #eee;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         padding: .3em .5em;
       }
       .topHeader {
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
index 7e0bf5a..603f610 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.html
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -39,7 +39,7 @@
       }
       .gr-form-styles .title {
         color: #666;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         padding-right: .5em;
         width: 15em;
       }
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
index e6e9c96..0c4d20f 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.html
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.html
@@ -44,14 +44,14 @@
         margin-top: 1em;
       }
       .navStyles .title {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         margin: .4em 0;
       }
       .navStyles .selected {
         background-color: #fff;
         border-bottom: 1px dotted #808080;
         border-top: 1px dotted #808080;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .navStyles a {
         color: black;
diff --git a/polygerrit-ui/app/styles/gr-table-styles.html b/polygerrit-ui/app/styles/gr-table-styles.html
index d296530..6b8c88d0 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.html
+++ b/polygerrit-ui/app/styles/gr-table-styles.html
@@ -34,7 +34,7 @@
       .genericList th {
         background-color: #ddd;
         border-bottom: 1px solid #eee;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         padding: .3em .5em;
         text-align: left;
       }
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css
index b18543a..045821c 100644
--- a/polygerrit-ui/app/styles/main.css
+++ b/polygerrit-ui/app/styles/main.css
@@ -37,6 +37,6 @@
    */
   -webkit-text-size-adjust: none;
   font-size: 13px;
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   line-height: 1.4;
 }
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index cc1dabd..5c11d60 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -38,6 +38,12 @@
         margin: 0;
         padding: 0;
       }
+      input,
+      textarea,
+      select,
+      button {
+        font: inherit;
+      }
       body {
         line-height: 1;
       }
@@ -59,15 +65,15 @@
       /* Other Shared Styles*/
       h1 {
         font-size: 2em;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       h2 {
         font-size: 1.5em;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       h3 {
         font-size: 1.17em;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       /* Stopgap solution until we remove hidden$ attributes. */
       [hidden] {
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
index 138d0ea..dffcaf9 100644
--- a/polygerrit-ui/app/template_test_srcs/template_test.js
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -27,13 +27,15 @@
   'GrGerritAuth',
   'GrLinkTextParser',
   'GrPluginEndpoints',
+  'GrPopupInterface',
   'GrRangeNormalizer',
   'GrReporting',
   'GrReviewerUpdatesParser',
   'GrThemeApi',
   'moment',
   'page',
-  'util'];
+  'util',
+];
 
 fs.readdir('./polygerrit-ui/temp/behaviors/', (err, data) => {
   if (err) {
@@ -102,4 +104,4 @@
       process.exit(1);
     }
   });
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 1df8d98..912b0ff 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -32,7 +32,6 @@
     'gr-app_test.html',
     'admin/gr-access-section/gr-access-section_test.html',
     'admin/gr-admin-group-list/gr-admin-group-list_test.html',
-    'admin/gr-admin-project-list/gr-admin-project-list_test.html',
     'admin/gr-admin-view/gr-admin-view_test.html',
     'admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html',
     'admin/gr-create-change-dialog/gr-create-change-dialog_test.html',
@@ -48,6 +47,7 @@
     'admin/gr-project-access/gr-project-access_test.html',
     'admin/gr-project-commands/gr-project-commands_test.html',
     'admin/gr-project-detail-list/gr-project-detail-list_test.html',
+    'admin/gr-project-list/gr-project-list_test.html',
     'admin/gr-rule-editor/gr-rule-editor_test.html',
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list-view/gr-change-list-view_test.html',
@@ -103,6 +103,8 @@
     'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
     'plugins/gr-external-style/gr-external-style_test.html',
     'plugins/gr-plugin-host/gr-plugin-host_test.html',
+    'plugins/gr-popup-interface/gr-plugin-popup_test.html',
+    'plugins/gr-popup-interface/gr-popup-interface_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-change-table-editor/gr-change-table-editor_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',