Merge "Make SubmoduleOp#createSubmoduleCommitMsg() less wasteful"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 04cdd14..a82370c 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4632,8 +4632,9 @@
 +
 By default "Anonymous Coward" is used.
 
+[[secure.config]]
+== File `etc/secure.config`
 
-== [[secure.config]]File `etc/secure.config`
 The optional file `'$site_path'/etc/secure.config` overrides (or
 supplements) the settings supplied by `'$site_path'/etc/gerrit.config`.
 The file should be readable only by the daemon process and can be
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 6722824..cb161af 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -517,64 +517,6 @@
   }
 ----
 
-[[create-merge-patch-set-for-change]]
-=== Create Merge Patch Set For Change
---
-'POST /changes/link:#change-id[\{change-id\}]/merge'
---
-
-Update an existing change by using a
-link:#merge-patch-set-input[MergePatchSetInput] entity.
-
-Gerrit will create a merge commit based on the information of
-MergePatchSetInput and add a new patch set to the change corresponding
-to the new merge commit.
-
-.Request
-----
-  POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "subject": "Merge dev_branch into master",
-    "merge": {
-      "source": "refs/12/1234/1"
-    }
-  }
-----
-
-As response a link:#change-info[ChangeInfo] entity with current revision is
-returned that describes the resulting change.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  {
-    "id": "test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc",
-    "project": "test",
-    "branch": "master",
-    "hashtags": [],
-    "change_id": "Ic5466d107c5294414710935a8ef3b0180fb848dc",
-    "subject": "Merge dev_branch into master",
-    "status": "NEW",
-    "created": "2016-09-23 18:08:53.238000000",
-    "updated": "2016-09-23 18:09:25.934000000",
-    "submit_type": "MERGE_IF_NECESSARY",
-    "mergeable": true,
-    "insertions": 5,
-    "deletions": 0,
-    "_number": 72,
-    "owner": {
-      "_account_id": 1000000
-    },
-    "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
-  }
-----
-
 [[get-change-detail]]
 === Get Change Detail
 --
@@ -806,6 +748,97 @@
   }
 ----
 
+[[create-merge-patch-set-for-change]]
+=== Create Merge Patch Set For Change
+--
+'POST /changes/link:#change-id[\{change-id\}]/merge'
+--
+
+Update an existing change by using a
+link:#merge-patch-set-input[MergePatchSetInput] entity.
+
+Gerrit will create a merge commit based on the information of
+MergePatchSetInput and add a new patch set to the change corresponding
+to the new merge commit.
+
+.Request
+----
+  POST /changes/test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc/merge  HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "subject": "Merge dev_branch into master",
+    "merge": {
+      "source": "refs/12/1234/1"
+    }
+  }
+----
+
+As response a link:#change-info[ChangeInfo] entity with current revision is
+returned that describes the resulting change.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "test~master~Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "project": "test",
+    "branch": "master",
+    "hashtags": [],
+    "change_id": "Ic5466d107c5294414710935a8ef3b0180fb848dc",
+    "subject": "Merge dev_branch into master",
+    "status": "NEW",
+    "created": "2016-09-23 18:08:53.238000000",
+    "updated": "2016-09-23 18:09:25.934000000",
+    "submit_type": "MERGE_IF_NECESSARY",
+    "mergeable": true,
+    "insertions": 5,
+    "deletions": 0,
+    "_number": 72,
+    "owner": {
+      "_account_id": 1000000
+    },
+    "current_revision": "27cc4558b5a3d3387dd11ee2df7a117e7e581822"
+  }
+----
+
+[[set-message]]
+=== Set Commit Message
+--
+'PUT /changes/link:#change-id[\{change-id\}]/message'
+--
+
+Creates a new patch set with a new commit message.
+
+The new commit message must be provided in the request body inside a
+link:#commit-message-input[CommitMessageInput] entity and contain the change ID footer if
+link:project-configuration.html#require-change-id[Require Change-Id] was specified.
+
+.Request
+----
+  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/message HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "message": "New Commit message \n\nChange-Id: I10394472cbd17dd12454f229e4f6de00b143a444\n"
+  }
+----
+
+.Notifications
+
+An email will be sent using the "newpatchset" template.
+
+[options="header",cols="1,1"]
+|=============================
+|WIP State       |Default
+|Ready for review|owner, reviewers, CCs, stars, NEW_PATCHSETS watchers
+|Work in progress|owner
+|=============================
+
 [[get-topic]]
 === Get Topic
 --
@@ -930,6 +963,8 @@
 Returns a list of every user ever assigned to a change, in the order in which
 they were first assigned.
 
+[NOTE] Past assignees are only available when NoteDb is enabled.
+
 .Request
 ----
   GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/past_assignees HTTP/1.0
@@ -2315,6 +2350,80 @@
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unmute HTTP/1.0
 ----
 
+[[get-hashtags]]
+=== Get Hashtags
+--
+'GET /changes/link:#change-id[\{change-id\}]/hashtags'
+--
+
+Gets the hashtags associated with a change.
+
+[NOTE] Hashtags are only available when NoteDb is enabled.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
+----
+
+As response the change's hashtags are returned as a list of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "hashtag1",
+    "hashtag2"
+  ]
+----
+
+[[set-hashtags]]
+=== Set Hashtags
+--
+'POST /changes/link:#change-id[\{change-id\}]/hashtags'
+--
+
+Adds and/or removes hashtags from a change.
+
+[NOTE] Hashtags are only available when NoteDb is enabled.
+
+The hashtags to add or remove must be provided in the request body inside a
+link:#hashtags-input[HashtagsInput] entity.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+  {
+    "add" : [
+      "hashtag3"
+    ],
+    "remove" : [
+      "hashtag2"
+    ]
+  }
+----
+
+As response the change's hashtags are returned as a list of strings.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  [
+    "hashtag1",
+    "hashtag3"
+  ]
+----
+
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -2684,78 +2793,6 @@
   HTTP/1.1 204 No Content
 ----
 
-[[get-hashtags]]
-=== Get Hashtags
---
-'GET /changes/link:#change-id[\{change-id\}]/hashtags'
---
-
-Gets the hashtags associated with a change.
-
-[NOTE] Hashtags are only available when NoteDb is enabled.
-
-.Request
-----
-  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
-----
-
-As response the change's hashtags are returned as a list of strings.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  [
-    "hashtag1",
-    "hashtag2"
-  ]
-----
-
-[[set-hashtags]]
-=== Set Hashtags
---
-'POST /changes/link:#change-id[\{change-id\}]/hashtags'
---
-
-Adds and/or removes hashtags from a change.
-
-[NOTE] Hashtags are only available when NoteDb is enabled.
-
-The hashtags to add or remove must be provided in the request body inside a
-link:#hashtags-input[HashtagsInput] entity.
-
-.Request
-----
-  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/hashtags HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "add" : [
-      "hashtag3"
-    ],
-    "remove" : [
-      "hashtag2"
-    ]
-  }
-----
-
-As response the change's hashtags are returned as a list of strings.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  [
-    "hashtag1",
-    "hashtag3"
-  ]
-----
 
 [[reviewer-endpoints]]
 == Reviewer Endpoints
@@ -3138,38 +3175,6 @@
   HTTP/1.1 204 No Content
 ----
 
-[[set-message]]
-=== Set Commit Message
---
-'PUT /changes/link:#change-id[\{change-id\}]/message'
---
-
-Creates a new patch set with a new commit message.
-
-The new commit message must be provided in the request body inside a
-link:#commit-message-input[CommitMessageInput] entity and contain the change ID footer if
-link:project-configuration.html#require-change-id[Require Change-Id] was specified.
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/message HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "message": "New Commit message \n\nChange-Id: I10394472cbd17dd12454f229e4f6de00b143a444\n"
-  }
-----
-
-.Notifications
-
-An email will be sent using the "newpatchset" template.
-
-[options="header",cols="1,1"]
-|=============================
-|WIP State       |Default
-|Ready for review|owner, reviewers, CCs, stars, NEW_PATCHSETS watchers
-|Work in progress|owner
-|=============================
 
 [[revision-endpoints]]
 == Revision Endpoints
@@ -5631,6 +5636,12 @@
 The name of the target branch. +
 The `refs/heads/` prefix is omitted.
 |`topic`              |optional|The topic to which this change belongs.
+|`assignee`           |optional|
+The assignee of the change as an link:rest-api-accounts.html#account-info[
+AccountInfo] entity.
+|`hashtags`           |optional|
+List of hashtags that are set on the change (only populated when NoteDb
+is enabled).
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
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 9af1836..f472b50 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
@@ -709,7 +709,8 @@
   @Test
   public void lookUpByPreferredEmail() throws Exception {
     // create an inconsistent account that has a preferred email without external ID
-    String prefEmail = "foo.preferred@example.com";
+    String prefix = "foo.preferred";
+    String prefEmail = prefix + "@example.com";
     TestAccount foo = accountCreator.create(name("foo"));
     accountsUpdate.create().update(db, foo.id, a -> a.setPreferredEmail(prefEmail));
 
@@ -717,6 +718,14 @@
     ImmutableSet<Account.Id> accountsByPrefEmail = emails.getAccountFor(prefEmail);
     assertThat(accountsByPrefEmail).hasSize(1);
     assertThat(Iterables.getOnlyElement(accountsByPrefEmail)).isEqualTo(foo.id);
+
+    // look up by email prefix doesn't find the account
+    accountsByPrefEmail = emails.getAccountFor(prefix);
+    assertThat(accountsByPrefEmail).isEmpty();
+
+    // look up by other case doesn't find the account
+    accountsByPrefEmail = emails.getAccountFor(prefEmail.toUpperCase(Locale.US));
+    assertThat(accountsByPrefEmail).isEmpty();
   }
 
   @Test
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
index 3114cb9..e5bc194 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/client/ProjectState.java
@@ -15,7 +15,23 @@
 package com.google.gerrit.extensions.client;
 
 public enum ProjectState {
-  ACTIVE,
-  READ_ONLY,
-  HIDDEN
+  ACTIVE(true, true),
+  READ_ONLY(true, false),
+  HIDDEN(false, false);
+
+  private final boolean permitsRead;
+  private final boolean permitsWrite;
+
+  ProjectState(boolean permitsRead, boolean permitsWrite) {
+    this.permitsRead = permitsRead;
+    this.permitsWrite = permitsWrite;
+  }
+
+  public boolean permitsRead() {
+    return permitsRead;
+  }
+
+  public boolean permitsWrite() {
+    return permitsWrite;
+  }
 }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 4f7a5ba..01aec6e 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -223,8 +223,6 @@
               .get()
               .byPreferredEmail(email)
               .stream()
-              // the index query also matches prefixes, filter those out
-              .filter(a -> email.equalsIgnoreCase(a.getAccount().getPreferredEmail()))
               .map(AccountState::getAccount)
               .findFirst();
       return match.isPresent() ? auth(match.get()) : null;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index e6d7e58..e4a1cd5 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -19,6 +19,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -29,6 +30,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
@@ -44,8 +46,9 @@
 import org.apache.commons.validator.routines.EmailValidator;
 
 public class InitAdminUser implements InitStep {
-  private final ConsoleUI ui;
   private final InitFlags flags;
+  private final ConsoleUI ui;
+  private final AllUsersNameOnInitProvider allUsers;
   private final AccountsOnInit accounts;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
   private final ExternalIdsOnInit externalIds;
@@ -58,6 +61,7 @@
   InitAdminUser(
       InitFlags flags,
       ConsoleUI ui,
+      AllUsersNameOnInitProvider allUsers,
       AccountsOnInit accounts,
       VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
       ExternalIdsOnInit externalIds,
@@ -65,6 +69,7 @@
       GroupsOnInit groupsOnInit) {
     this.flags = flags;
     this.ui = ui;
+    this.allUsers = allUsers;
     this.accounts = accounts;
     this.authorizedKeysFactory = authorizedKeysFactory;
     this.externalIds = externalIds;
@@ -128,7 +133,11 @@
 
           AccountState as =
               new AccountState(
-                  a, Collections.singleton(adminGroup.getGroupUUID()), extIds, new HashMap<>());
+                  new AllUsersName(allUsers.get()),
+                  a,
+                  Collections.singleton(adminGroup.getGroupUUID()),
+                  extIds,
+                  new HashMap<>());
           for (AccountIndex accountIndex : indexCollection.getWriteIndexes()) {
             accountIndex.replace(as);
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 6cb4444..16901ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.group.Groups;
 import com.google.gerrit.server.index.account.AccountIndexer;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -76,15 +77,18 @@
     };
   }
 
+  private final AllUsersName allUsersName;
   private final LoadingCache<Account.Id, Optional<AccountState>> byId;
   private final LoadingCache<String, Optional<Account.Id>> byName;
   private final Provider<AccountIndexer> indexer;
 
   @Inject
   AccountCacheImpl(
+      AllUsersName allUsersName,
       @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
       @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
       Provider<AccountIndexer> indexer) {
+    this.allUsersName = allUsersName;
     this.byId = byId;
     this.byName = byUsername;
     this.indexer = indexer;
@@ -142,16 +146,21 @@
     }
   }
 
-  private static AccountState missing(Account.Id accountId) {
+  private AccountState missing(Account.Id accountId) {
     Account account = new Account(accountId, TimeUtil.nowTs());
     account.setActive(false);
     Set<AccountGroup.UUID> anon = ImmutableSet.of();
     return new AccountState(
-        account, anon, Collections.emptySet(), new HashMap<ProjectWatchKey, Set<NotifyType>>());
+        allUsersName,
+        account,
+        anon,
+        Collections.emptySet(),
+        new HashMap<ProjectWatchKey, Set<NotifyType>>());
   }
 
   static class ByIdLoader extends CacheLoader<Account.Id, Optional<AccountState>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final AllUsersName allUsersName;
     private final Accounts accounts;
     private final GroupCache groupCache;
     private final Groups groups;
@@ -163,6 +172,7 @@
     @Inject
     ByIdLoader(
         SchemaFactory<ReviewDb> sf,
+        AllUsersName allUsersName,
         Accounts accounts,
         GroupCache groupCache,
         Groups groups,
@@ -170,8 +180,9 @@
         @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
         Provider<WatchConfig.Accessor> watchConfig,
         ExternalIds externalIds) {
-      this.accounts = accounts;
       this.schema = sf;
+      this.allUsersName = allUsersName;
+      this.accounts = accounts;
       this.groupCache = groupCache;
       this.groups = groups;
       this.loader = loader;
@@ -219,6 +230,7 @@
 
       return Optional.of(
           new AccountState(
+              allUsersName,
               account,
               internalGroups,
               externalIds.byAccount(who),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index 1eaf34f..dd523a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
@@ -43,6 +44,7 @@
   public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
       a -> a.getAccount().getId();
 
+  private final AllUsersName allUsersName;
   private final Account account;
   private final Set<AccountGroup.UUID> internalGroups;
   private final Collection<ExternalId> externalIds;
@@ -50,10 +52,12 @@
   private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
 
   public AccountState(
+      AllUsersName allUsersName,
       Account account,
       Set<AccountGroup.UUID> actualGroups,
       Collection<ExternalId> externalIds,
       Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+    this.allUsersName = allUsersName;
     this.account = account;
     this.internalGroups = actualGroups;
     this.externalIds = externalIds;
@@ -61,6 +65,10 @@
     this.account.setUserName(getUserName(externalIds));
   }
 
+  public AllUsersName getAllUsersNameForIndexing() {
+    return allUsersName;
+  }
+
   /** Get the cached account metadata. */
   public Account getAccount() {
     return account;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
index db707a8..3e97265 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
@@ -27,7 +27,6 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
 
 /** Class to access accounts by email. */
 @Singleton
@@ -61,15 +60,9 @@
    * @see #getAccountsFor(String...)
    */
   public ImmutableSet<Account.Id> getAccountFor(String email) throws IOException, OrmException {
-    List<AccountState> byPreferredEmail = queryProvider.get().byPreferredEmail(email);
     return Streams.concat(
             externalIds.byEmail(email).stream().map(e -> e.accountId()),
-            byPreferredEmail
-                .stream()
-                // the index query also matches prefixes and emails with other case,
-                // filter those out
-                .filter(a -> email.equals(a.getAccount().getPreferredEmail()))
-                .map(a -> a.getAccount().getId()))
+            queryProvider.get().byPreferredEmail(email).stream().map(a -> a.getAccount().getId()))
         .collect(toImmutableSet());
   }
 
@@ -91,9 +84,6 @@
         .byPreferredEmail(emails)
         .entries()
         .stream()
-        // the index query also matches prefixes and emails with other case,
-        // filter those out
-        .filter(e -> e.getKey().equals(e.getValue().getAccount().getPreferredEmail()))
         .forEach(e -> builder.put(e.getKey(), e.getValue().getAccount().getId()));
     return builder.build();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 8c8da7b..ad119ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -19,6 +19,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.hash.Hashing;
@@ -31,6 +32,7 @@
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
@@ -176,7 +178,8 @@
         extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
   }
 
-  static ExternalId create(
+  @VisibleForTesting
+  public static ExternalId create(
       Key key,
       Account.Id accountId,
       @Nullable String email,
@@ -305,6 +308,15 @@
     return key().isScheme(scheme);
   }
 
+  public byte[] toByteArray() {
+    checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
+    byte[] b = new byte[2 * Constants.OBJECT_ID_STRING_LENGTH + 1];
+    key().sha1().copyTo(b, 0);
+    b[Constants.OBJECT_ID_STRING_LENGTH] = ':';
+    blobId().copyTo(b, Constants.OBJECT_ID_STRING_LENGTH + 1);
+    return b;
+  }
+
   /**
    * For checking if two external IDs are equals the blobId is excluded and external IDs that have
    * different blob IDs but identical other fields are considered equal. This way an external ID
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 eb005af..fcd6e0d 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
@@ -128,7 +128,9 @@
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.CreateRefControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
@@ -320,6 +322,7 @@
   private final String canonicalWebUrl;
   private final SubmoduleOp.Factory subOpFactory;
   private final TagCache tagCache;
+  private final CreateRefControl createRefControl;
 
   // Assisted injected fields.
   private final AllRefsWatcher allRefsWatcher;
@@ -402,6 +405,7 @@
       SshInfo sshInfo,
       SubmoduleOp.Factory subOpFactory,
       TagCache tagCache,
+      CreateRefControl createRefControl,
       @Assisted ProjectControl projectControl,
       @Assisted ReceivePack rp,
       @Assisted AllRefsWatcher allRefsWatcher,
@@ -440,6 +444,7 @@
     this.sshInfo = sshInfo;
     this.subOpFactory = subOpFactory;
     this.tagCache = tagCache;
+    this.createRefControl = createRefControl;
 
     // Assisted injected fields.
     this.allRefsWatcher = allRefsWatcher;
@@ -524,7 +529,7 @@
 
     try {
       parseCommands(commands);
-    } catch (PermissionBackendException err) {
+    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
       for (ReceiveCommand cmd : actualCommands) {
         if (cmd.getResult() == NOT_ATTEMPTED) {
           cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
@@ -772,7 +777,7 @@
   }
 
   private void parseCommands(Collection<ReceiveCommand> commands)
-      throws PermissionBackendException {
+      throws PermissionBackendException, NoSuchProjectException, IOException {
     List<String> optionList = rp.getPushOptions();
     if (optionList != null) {
       for (String option : optionList) {
@@ -977,7 +982,8 @@
     }
   }
 
-  private void parseCreate(ReceiveCommand cmd) throws PermissionBackendException {
+  private void parseCreate(ReceiveCommand cmd)
+      throws PermissionBackendException, NoSuchProjectException, IOException {
     RevObject obj;
     try {
       obj = rp.getRevWalk().parseAny(cmd.getNewId());
@@ -994,8 +1000,8 @@
       return;
     }
 
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    String rejectReason = ctl.canCreate(rp.getRepository(), obj);
+    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);
       return;
@@ -1006,6 +1012,7 @@
       return;
     }
 
+    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
     validateNewCommits(ctl, cmd);
     actualCommands.add(cmd);
   }
@@ -1446,9 +1453,8 @@
     magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
     magicBranch.ctl = projectControl.controlForRef(ref);
     magicBranch.perm = permissions.ref(ref);
-    if (projectControl.getProject().getState()
-        != com.google.gerrit.extensions.client.ProjectState.ACTIVE) {
-      reject(cmd, "project is read only");
+    if (!projectControl.getProject().getState().permitsWrite()) {
+      reject(cmd, "project state does not permit write");
       return;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java
new file mode 100644
index 0000000..b55afb6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java
@@ -0,0 +1,65 @@
+// 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.index;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@AutoValue
+public abstract class RefState {
+  public static RefState create(String ref, String sha) {
+    return new AutoValue_RefState(ref, ObjectId.fromString(sha));
+  }
+
+  public static RefState create(String ref, @Nullable ObjectId id) {
+    return new AutoValue_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
+  }
+
+  public static RefState of(Ref ref) {
+    return new AutoValue_RefState(ref.getName(), ref.getObjectId());
+  }
+
+  public byte[] toByteArray(Project.NameKey project) {
+    byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
+    byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
+    System.arraycopy(a, 0, b, 0, a.length);
+    id().copyTo(b, a.length);
+    return b;
+  }
+
+  public static void check(boolean condition, String str) {
+    checkArgument(condition, "invalid RefState: %s", str);
+  }
+
+  public abstract String ref();
+
+  public abstract ObjectId id();
+
+  public boolean match(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(ref());
+    ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
+    return id().equals(expected);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index b7c5e77..5e12c12 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -17,20 +17,26 @@
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.integer;
 import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
 import static com.google.gerrit.index.FieldDef.timestamp;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.index.RefState;
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.Locale;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for accounts. */
 public class AccountField {
@@ -84,6 +90,9 @@
                 return preferredEmail != null ? preferredEmail.toLowerCase() : null;
               });
 
+  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
+      exact("preferredemail_exact").build(a -> a.getAccount().getPreferredEmail());
+
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
       timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
 
@@ -98,5 +107,43 @@
                       .transform(k -> k.project().get())
                       .toSet());
 
+  /**
+   * All values of all refs that were used in the course of indexing this document, except the
+   * refs/meta/external-ids notes branch which is handled specially (see {@link
+   * #EXTERNAL_ID_STATE}).
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
+      storedOnly("ref_state")
+          .buildRepeatable(
+              a -> {
+                if (a.getAccount().getMetaId() == null) {
+                  return ImmutableList.of();
+                }
+
+                return ImmutableList.of(
+                    RefState.create(
+                            RefNames.refsUsers(a.getAccount().getId()),
+                            ObjectId.fromString(a.getAccount().getMetaId()))
+                        .toByteArray(a.getAllUsersNameForIndexing()));
+              });
+
+  /**
+   * All note values of all external IDs that were used in the course of indexing this document.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
+   * note blob]}, or with other words {@code [note ID]:[note data ID]}.
+   */
+  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
+      storedOnly("external_id_state")
+          .buildRepeatable(
+              a ->
+                  a.getExternalIds()
+                      .stream()
+                      .filter(e -> e.blobId() != null)
+                      .map(e -> e.toByteArray())
+                      .collect(toSet()));
+
   private AccountField() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
index 67b507d..2a14f9b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
@@ -18,13 +18,11 @@
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class AccountIndexCollection
     extends IndexCollection<Account.Id, AccountState, AccountIndex> {
-  @Inject
   @VisibleForTesting
   public AccountIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 8f9b443..dcdf9e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -34,7 +34,13 @@
           AccountField.USERNAME,
           AccountField.WATCHED_PROJECT);
 
-  static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
+  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
+
+  @Deprecated
+  static final Schema<AccountState> V6 =
+      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
+
+  static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
 
   public static final String NAME = "accounts";
   public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index cc8f9be..a1c7f14 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -50,7 +50,7 @@
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
index 5ce361f..a353a2a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
@@ -18,12 +18,10 @@
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class ChangeIndexCollection extends IndexCollection<Change.Id, ChangeData, ChangeIndex> {
-  @Inject
   @VisibleForTesting
   public ChangeIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
index df92379..e804702 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.index.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -35,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -47,8 +47,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
@@ -221,43 +219,6 @@
     }
   }
 
-  @AutoValue
-  public abstract static class RefState {
-    static RefState create(String ref, String sha) {
-      return new AutoValue_StalenessChecker_RefState(ref, ObjectId.fromString(sha));
-    }
-
-    static RefState create(String ref, @Nullable ObjectId id) {
-      return new AutoValue_StalenessChecker_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
-    }
-
-    static RefState of(Ref ref) {
-      return new AutoValue_StalenessChecker_RefState(ref.getName(), ref.getObjectId());
-    }
-
-    byte[] toByteArray(Project.NameKey project) {
-      byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
-      byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
-      System.arraycopy(a, 0, b, 0, a.length);
-      id().copyTo(b, a.length);
-      return b;
-    }
-
-    private static void check(boolean condition, String str) {
-      checkArgument(condition, "invalid RefState: %s", str);
-    }
-
-    abstract String ref();
-
-    abstract ObjectId id();
-
-    private boolean match(Repository repo) throws IOException {
-      Ref ref = repo.exactRef(ref());
-      ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
-      return id().equals(expected);
-    }
-  }
-
   /**
    * Pattern for matching refs.
    *
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
index 5c49ee5..5ce65a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -17,13 +17,11 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GroupIndexCollection
     extends IndexCollection<AccountGroup.UUID, AccountGroup, GroupIndex> {
-  @Inject
   @VisibleForTesting
   public GroupIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
index b43dc16..16ede58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
@@ -105,8 +105,6 @@
     this.patches = patches;
   }
 
-  protected PatchList() {}
-
   /** Old side tree or commit; null only if this is a combined diff. */
   @Nullable
   public ObjectId getOldId() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index be5a7aa..b985723 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -19,7 +19,6 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Change;
@@ -31,7 +30,6 @@
 import com.google.inject.Module;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
-import java.util.List;
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
@@ -198,15 +196,11 @@
     private static final long serialVersionUID = 1L;
 
     @VisibleForTesting
-    public LargeObjectTombstone() {}
-
-    /**
-     * Return an empty list to prevent {@link NullPointerException}s inside of {@link
-     * PatchListWeigher}.
-     */
-    @Override
-    public List<PatchListEntry> getPatches() {
-      return ImmutableList.of();
+    public LargeObjectTombstone() {
+      // Initialize super class with valid values. We don't care about the inner state, but need to
+      // pass valid values that don't break (de)serialization.
+      super(
+          null, ObjectId.zeroId(), false, ComparisonType.againstAutoMerge(), new PatchListEntry[0]);
     }
   }
 }
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 ea4166f..f1ebf64 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
@@ -230,6 +230,7 @@
 
   /** Can this user publish this draft change or any draft patch set of this change? */
   public boolean canPublish(ReviewDb db) throws OrmException {
+    // TODO(hiesel) These don't need to be migrated, just remove after support for drafts is removed
     return (isOwner() || getRefControl().canPublishDrafts()) && isVisible(db);
   }
 
@@ -439,6 +440,7 @@
   }
 
   public boolean isDraftVisible(ReviewDb db, ChangeData cd) throws OrmException {
+    // TODO(hiesel) These don't need to be migrated, just remove after support for drafts is removed
     return isOwner()
         || isReviewer(db, cd)
         || getRefControl().canViewDrafts()
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 4e2e327..77fb86b 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
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
@@ -55,6 +56,7 @@
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refCreationValidator;
+  private final CreateRefControl createRefControl;
   private String ref;
 
   @Inject
@@ -64,18 +66,21 @@
       GitRepositoryManager repoManager,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refHelperFactory,
+      CreateRefControl createRefControl,
       @Assisted String ref) {
     this.identifiedUser = identifiedUser;
     this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.referenceUpdated = referenceUpdated;
     this.refCreationValidator = refHelperFactory.create(ReceiveCommand.Type.CREATE);
+    this.createRefControl = createRefControl;
     this.ref = ref;
   }
 
   @Override
   public BranchInfo apply(ProjectResource rsrc, BranchInput input)
-      throws BadRequestException, AuthException, ResourceConflictException, IOException {
+      throws BadRequestException, AuthException, ResourceConflictException, IOException,
+          PermissionBackendException, NoSuchProjectException {
     if (input == null) {
       input = new BranchInput();
     }
@@ -100,7 +105,6 @@
     }
 
     final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
-    final RefControl refControl = rsrc.getControl().controlForRef(name);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -117,7 +121,7 @@
         }
       }
 
-      String rejectReason = refControl.canCreate(repo, object);
+      String rejectReason = createRefControl.canCreateRef(repo, object, identifiedUser.get(), name);
       if (rejectReason != null) {
         throw new AuthException("Cannot create \"" + ref + "\": " + rejectReason);
       }
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
new file mode 100644
index 0000000..aa48a73
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java
@@ -0,0 +1,177 @@
+// 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.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.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Manages access control for creating Git references (aka branches, tags). */
+@Singleton
+public class CreateRefControl {
+  private static final Logger log = LoggerFactory.getLogger(CreateRefControl.class);
+
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+
+  @Inject
+  CreateRefControl(PermissionBackend permissionBackend, ProjectCache projectCache) {
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
+  }
+
+  /**
+   * Determines whether the user 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
+   */
+  @Nullable
+  public String canCreateRef(
+      Repository repo, RevObject object, IdentifiedUser user, Branch.NameKey branch)
+      throws 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";
+    }
+
+    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);
+    } else if (object instanceof RevTag) {
+      final 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";
+      }
+
+      // 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;
+        }
+      }
+
+      RevObject tagObject = tag.getObject();
+      if (tagObject instanceof RevCommit) {
+        String rejectReason = canCreateCommit(repo, (RevCommit) tagObject, ps, user, perm);
+        if (rejectReason != null) {
+          return rejectReason;
+        }
+      } else {
+        String rejectReason = canCreateRef(repo, tagObject, user, branch);
+        if (rejectReason != null) {
+          return rejectReason;
+        }
+      }
+
+      // 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);
+      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
+        return refControl.canPerform(Permission.CREATE_SIGNED_TAG)
+            ? null
+            : user.getAccountId() + " lacks permission: " + Permission.CREATE_SIGNED_TAG;
+      }
+      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.
+   */
+  @Nullable
+  private String canCreateCommit(
+      Repository repo,
+      RevCommit commit,
+      ProjectState projectState,
+      IdentifiedUser user,
+      PermissionBackend.ForRef forRef)
+      throws PermissionBackendException {
+    if (projectState.controlFor(user).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 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;
+  }
+}
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 16c820f..a749759 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
@@ -16,11 +16,9 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -34,7 +32,6 @@
 import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -44,19 +41,9 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Manages access control for Git references (aka branches, tags). */
 public class RefControl {
-  private static final Logger log = LoggerFactory.getLogger(RefControl.class);
-
   private final ProjectControl projectControl;
   private final String refName;
 
@@ -209,12 +196,11 @@
   }
 
   private boolean isProjectStatePermittingWrite() {
-    return getProjectControl().getProject().getState().equals(ProjectState.ACTIVE);
+    return getProjectControl().getProject().getState().permitsWrite();
   }
 
   private boolean isProjectStatePermittingRead() {
-    return getProjectControl().getProject().getState().equals(ProjectState.READ_ONLY)
-        || isProjectStatePermittingWrite();
+    return getProjectControl().getProject().getState().permitsRead();
   }
 
   private boolean canPushWithForce() {
@@ -231,108 +217,6 @@
   }
 
   /**
-   * Determines whether the user 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.
-   * @return {@code null} if the user specified can create a new Git ref, or a String describing why
-   *     the creation is not allowed.
-   */
-  @Nullable
-  public String canCreate(Repository repo, RevObject object) {
-    if (!isProjectStatePermittingWrite()) {
-      return "project state does not permit write";
-    }
-
-    String userId =
-        getUser().isIdentifiedUser() ? "account " + getUser().getAccountId() : "anonymous user";
-
-    if (object instanceof RevCommit) {
-      if (!canPerform(Permission.CREATE)) {
-        return userId + " lacks permission: " + Permission.CREATE;
-      }
-      return canCreateCommit(repo, (RevCommit) object, userId);
-    } else if (object instanceof RevTag) {
-      final 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:",
-                projectControl.getProject().getNameKey(), tag.name());
-        log.error(msg, e);
-
-        return "I/O exception for revwalk";
-      }
-
-      // If tagger is present, require it matches the user's email.
-      //
-      final PersonIdent tagger = tag.getTaggerIdent();
-      if (tagger != null) {
-        boolean valid;
-        if (getUser().isIdentifiedUser()) {
-          final String addr = tagger.getEmailAddress();
-          valid = getUser().asIdentifiedUser().hasEmailAddress(addr);
-        } else {
-          valid = false;
-        }
-        if (!valid && !canForgeCommitter()) {
-          return userId + " lacks permission: " + Permission.FORGE_COMMITTER;
-        }
-      }
-
-      RevObject tagObject = tag.getObject();
-      if (tagObject instanceof RevCommit) {
-        String rejectReason = canCreateCommit(repo, (RevCommit) tagObject, userId);
-        if (rejectReason != null) {
-          return rejectReason;
-        }
-      } else {
-        String rejectReason = canCreate(repo, tagObject);
-        if (rejectReason != null) {
-          return rejectReason;
-        }
-      }
-
-      // If the tag has a PGP signature, allow a lower level of permission
-      // than if it doesn't have a PGP signature.
-      //
-      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
-        return canPerform(Permission.CREATE_SIGNED_TAG)
-            ? null
-            : userId + " lacks permission: " + Permission.CREATE_SIGNED_TAG;
-      }
-      return canPerform(Permission.CREATE_TAG)
-          ? null
-          : userId + " 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.
-   */
-  @Nullable
-  private String canCreateCommit(Repository repo, RevCommit commit, String userId) {
-    if (canUpdate()) {
-      // 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;
-    } else if (projectControl.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;
-    }
-    return userId + " lacks permission " + Permission.PUSH + " for creating new commit object";
-  }
-
-  /**
    * Determines whether the user can delete the Git ref controlled by this object.
    *
    * @return {@code true} if the user specified can delete a Git ref.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
index d6552e2..9213353 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -66,6 +66,11 @@
         email.toLowerCase());
   }
 
+  public static Predicate<AccountState> preferredEmailExact(String email) {
+    return new AccountPredicate(
+        AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
+  }
+
   public static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
         AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 9358a7a..946a729 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -37,6 +37,7 @@
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_NAME = "name";
   public static final String FIELD_PREFERRED_EMAIL = "preferredemail";
+  public static final String FIELD_PREFERRED_EMAIL_EXACT = "preferredemail_exact";
   public static final String FIELD_USERNAME = "username";
   public static final String FIELD_VISIBLETO = "visibleto";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 4821e6f..bbcb811 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.query.account;
 
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ArrayListMultimap;
@@ -25,6 +26,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -113,17 +115,60 @@
     return query(AccountPredicates.fullName(fullName));
   }
 
+  /**
+   * Queries for accounts that have a preferred email that exactly matches the given email.
+   *
+   * @param email preferred email by which accounts should be found
+   * @return list of accounts that have a preferred email that exactly matches the given email
+   * @throws OrmException if query cannot be parsed
+   */
   public List<AccountState> byPreferredEmail(String email) throws OrmException {
-    return query(AccountPredicates.preferredEmail(email));
+    if (schema().hasField(AccountField.PREFERRED_EMAIL_EXACT)) {
+      return query(AccountPredicates.preferredEmailExact(email));
+    }
+
+    return query(AccountPredicates.preferredEmail(email))
+        .stream()
+        .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+        .collect(toList());
   }
 
+  /**
+   * Makes multiple queries for accounts by preferred email (exact match).
+   *
+   * @param emails preferred emails by which accounts should be found
+   * @return multimap of the given emails to accounts that have a preferred email that exactly
+   *     matches this email
+   * @throws OrmException if query cannot be parsed
+   */
   public Multimap<String, AccountState> byPreferredEmail(String... emails) throws OrmException {
     List<String> emailList = Arrays.asList(emails);
+
+    if (schema().hasField(AccountField.PREFERRED_EMAIL_EXACT)) {
+      List<List<AccountState>> r =
+          query(
+              emailList
+                  .stream()
+                  .map(e -> AccountPredicates.preferredEmailExact(e))
+                  .collect(toList()));
+      Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
+      for (int i = 0; i < emailList.size(); i++) {
+        accountsByEmail.putAll(emailList.get(i), r.get(i));
+      }
+      return accountsByEmail;
+    }
+
     List<List<AccountState>> r =
         query(emailList.stream().map(e -> AccountPredicates.preferredEmail(e)).collect(toList()));
     Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
     for (int i = 0; i < emailList.size(); i++) {
-      accountsByEmail.putAll(emailList.get(i), r.get(i));
+      String email = emailList.get(i);
+      Set<AccountState> matchingAccounts =
+          r.get(i)
+              .stream()
+              .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+              .collect(toSet());
+      accountsByEmail.putAll(email, matchingAccounts);
     }
     return accountsByEmail;
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java
new file mode 100644
index 0000000..7eed034
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -0,0 +1,94 @@
+// 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.index.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.testutil.GerritBaseTests;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class AccountFieldTest extends GerritBaseTests {
+  @Test
+  public void refStateFieldValues() throws Exception {
+    AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
+    account.setMetaId(metaId);
+    List<String> values =
+        toStrings(
+            AccountField.REF_STATE.get(
+                new AccountState(
+                    allUsersName,
+                    account,
+                    ImmutableSet.of(),
+                    ImmutableSet.of(),
+                    ImmutableMap.of())));
+    assertThat(values).hasSize(1);
+    String expectedValue =
+        allUsersName.get() + ":" + RefNames.refsUsers(account.getId()) + ":" + metaId;
+    assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
+  }
+
+  @Test
+  public void externalIdStateFieldValues() throws Exception {
+    Account.Id id = new Account.Id(1);
+    Account account = new Account(id, TimeUtil.nowTs());
+    ExternalId extId1 =
+        ExternalId.create(
+            ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com"),
+            id,
+            "foo.bar@example.com",
+            null,
+            ObjectId.fromString("1b9a0cf038ea38a0ab08617c39aa8e28413a27ca"));
+    ExternalId extId2 =
+        ExternalId.create(
+            ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "foo"),
+            id,
+            null,
+            "secret",
+            ObjectId.fromString("5b3a73dc9a668a5b89b5f049225261e3e3291d1a"));
+    List<String> values =
+        toStrings(
+            AccountField.EXTERNAL_ID_STATE.get(
+                new AccountState(
+                    null,
+                    account,
+                    ImmutableSet.of(),
+                    ImmutableSet.of(extId1, extId2),
+                    ImmutableMap.of())));
+    String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
+    String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
+    assertThat(values).containsExactly(expectedValue1, expectedValue2);
+  }
+
+  private List<String> toStrings(Iterable<byte[]> values) {
+    return Streams.stream(values).map(v -> new String(v, UTF_8)).collect(toList());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index 0af642d..b25ed2b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.testutil.GerritBaseTests;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index aaf723a..5215561 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.mail.Address;
 import java.util.Arrays;
 import java.util.Collections;
@@ -383,6 +385,10 @@
     account.setFullName(name);
     account.setPreferredEmail(email);
     return new AccountState(
-        account, Collections.emptySet(), Collections.emptySet(), new HashMap<>());
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        account,
+        Collections.emptySet(),
+        Collections.emptySet(),
+        new HashMap<>());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
index 19adf32..0a7b97cc 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
@@ -17,6 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.Patch;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
 import java.util.Arrays;
 import java.util.Comparator;
 import org.junit.Test;
@@ -65,4 +70,21 @@
         });
     assertThat(names).isEqualTo(want);
   }
+
+  @Test
+  public void largeObjectTombstoneCanBeSerializedAndDeserialized() throws Exception {
+    // Serialize
+    byte[] serializedObject;
+    try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        ObjectOutputStream objectStream = new ObjectOutputStream(baos)) {
+      objectStream.writeObject(new PatchListCacheImpl.LargeObjectTombstone());
+      serializedObject = baos.toByteArray();
+      assertThat(serializedObject).isNotNull();
+    }
+    // Deserialize
+    try (InputStream is = new ByteArrayInputStream(serializedObject);
+        ObjectInputStream ois = new ObjectInputStream(is)) {
+      assertThat(ois.readObject()).isInstanceOf(PatchListCacheImpl.LargeObjectTombstone.class);
+    }
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
index 242f208..c3b588b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -78,6 +80,11 @@
   }
 
   private static AccountState newState(Account account) {
-    return new AccountState(account, ImmutableSet.of(), ImmutableSet.of(), new HashMap<>());
+    return new AccountState(
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        account,
+        ImmutableSet.of(),
+        ImmutableSet.of(),
+        new HashMap<>());
   }
 }
diff --git a/plugins/replication b/plugins/replication
index 35d87c0..297b749 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 35d87c092ea7ef55085f2608917a85b2bd909b2f
+Subproject commit 297b749038153527291b43cb08b162eb475adcd7
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 9051695..e3358d4 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -37,554 +37,32 @@
     getReporting().timeEnd('WebComponentsReady');
   });
 
-  const encode = window.Gerrit.URLEncodingBehavior.encodeURL;
-  const patchNumEquals = window.Gerrit.PatchSetBehavior.patchNumEquals;
-  const EDIT_NAME = window.Gerrit.PatchSetBehavior.EDIT_NAME;
-
-  function startRouter(generateUrl) {
-    const base = window.Gerrit.BaseUrlBehavior.getBaseUrl();
-    if (base) {
-      page.base(base);
-    }
-
-    const restAPI = document.createElement('gr-rest-api-interface');
-    const reporting = getReporting();
-
-    Gerrit.Nav.setup(url => { page.show(url); }, generateUrl);
-
-    /**
-     * Given a set of params without a project, gets the project from the rest
-     * API project lookup and then sets the app params.
-     *
-     * @param {?Object} params
-     */
-    const normalizeLegacyRouteParams = params => {
-      if (!params.changeNum) { return; }
-
-      restAPI.getFromProjectLookup(params.changeNum).then(project => {
-        params.project = project;
-        normalizePatchRangeParams(params);
-        page.redirect(generateUrl(params));
-      });
-    };
-
-    // Middleware
-    page((ctx, next) => {
-      document.body.scrollTop = 0;
-
-      // Fire asynchronously so that the URL is changed by the time the event
-      // is processed.
-      app.async(() => {
-        app.fire('location-change', {
-          hash: window.location.hash,
-          pathname: window.location.pathname,
-        });
-        reporting.locationChanged();
-      }, 1);
-      next();
-    });
-
-    function loadUser(ctx, next) {
-      restAPI.getLoggedIn().then(() => { next(); });
-    }
-
-    // Routes.
-    page('/', loadUser, data => {
-      if (data.querystring.match(/^closeAfterLogin/)) {
-        // Close child window on redirect after login.
-        window.close();
-      }
-      // For backward compatibility with GWT links.
-      if (data.hash) {
-        // In certain login flows the server may redirect to a hash without
-        // a leading slash, which page.js doesn't handle correctly.
-        if (data.hash[0] !== '/') {
-          data.hash = '/' + data.hash;
-        }
-        if (data.hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
-          // Path decodes all '+' to ' ' -- this breaks project-based URLs.
-          // See Issue 6888.
-          data.hash = data.hash.replace('/ /', '/+/');
-        }
-        const hash = data.hash;
-        let newUrl = base + hash;
-        if (hash.startsWith('/VE/')) {
-          newUrl = base + '/settings' + data.hash;
-        }
-        page.redirect(newUrl);
-        return;
-      }
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          page.redirect('/dashboard/self');
-        } else {
-          page.redirect('/q/status:open');
-        }
-      });
-    });
-
-    function redirectToLogin(data) {
-      const basePath = base || '';
-      page('/login/' + encodeURIComponent(data.substring(basePath.length)));
-    }
-
-    page('/dashboard/(.*)', loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          data.params.view = Gerrit.Nav.View.DASHBOARD;
-          app.params = data.params;
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    // Matches /admin/groups/<group>,info (backwords compat with gwtui)
-    // Redirects to /admin/groups/<group>
-    page(/^\/admin\/groups\/(.+),info$/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          page.redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    // Matches /admin/groups/<group>,audit-log
-    page(/^\/admin\/groups\/(.+),audit-log$/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-group-audit-log',
-            detailType: 'audit-log',
-            groupId: data.params[0],
-          };
-        } else {
-          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
-        }
-      });
-    });
-
-    // Matches /admin/groups/<group>,members
-    page(/^\/admin\/groups\/(.+),members$/, loadUser, data => {
-      app.params = {
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-group-members',
-        detailType: 'members',
-        groupId: data.params[0],
-      };
-    });
-
-    // Matches /admin/groups[,<offset>][/].
-    page(/^\/admin\/groups(,(\d+))?(\/)?$/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
-            offset: data.params[1] || 0,
-            filter: null,
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page('/admin/groups/q/filter::filter,:offset', loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
-            offset: data.params.offset,
-            filter: data.params.filter,
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page('/admin/groups/q/filter::filter', loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-admin-group-list',
-            filter: data.params.filter || null,
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    // Matches /admin/groups/<group>
-    page(/^\/admin\/groups\/([^,]+)$/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-group',
-            groupId: data.params[0],
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    // Matches /admin/projects/<project>,commands.
-    page(/^\/admin\/projects\/(.+),commands$/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project-commands',
-            detailType: 'commands',
-            project: data.params[0],
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    // Matches /admin/projects/<project>,branches[,<offset>].
-    page(/^\/admin\/projects\/(.+),branches(,(.+))?$/, loadUser, data => {
-      app.params = {
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'branches',
-        project: data.params[0],
-        offset: data.params[2] || 0,
-        filter: null,
-      };
-    });
-
-    page('/admin/projects/:project,branches/q/filter::filter,:offset',
-        loadUser, data => {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project-detail-list',
-            detailType: 'branches',
-            project: data.params.project,
-            offset: data.params.offset,
-            filter: data.params.filter,
-          };
-        });
-
-    page('/admin/projects/:project,branches/q/filter::filter',
-        loadUser, data => {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project-detail-list',
-            detailType: 'branches',
-            project: data.params.project,
-            filter: data.params.filter || null,
-          };
-        });
-
-    // Matches /admin/projects/<project>,tags[,<offset>].
-    page(/^\/admin\/projects\/(.+),tags(,(.+))?$/, loadUser, data => {
-      app.params = {
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-project-detail-list',
-        detailType: 'tags',
-        project: data.params[0],
-        offset: data.params[2] || 0,
-        filter: null,
-      };
-    });
-
-    page('/admin/projects/:project,tags/q/filter::filter,:offset',
-        loadUser, data => {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project-detail-list',
-            detailType: 'tags',
-            project: data.params.project,
-            offset: data.params.offset,
-            filter: data.params.filter,
-          };
-        });
-
-    page('/admin/projects/:project,tags/q/filter::filter',
-        loadUser, data => {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-project-detail-list',
-            detailType: 'tags',
-            project: data.params.project,
-            filter: data.params.filter || null,
-          };
-        });
-
-    // Matches /admin/projects[,<offset>][/].
-    page(/^\/admin\/projects(,(\d+))?(\/)?$/, loadUser, data => {
-      app.params = {
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-project-list',
-        offset: data.params[1] || 0,
-        filter: null,
-      };
-    });
-
-    page('/admin/projects/q/filter::filter,:offset', loadUser, data => {
-      app.params = {
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-project-list',
-        offset: data.params.offset,
-        filter: data.params.filter,
-      };
-    });
-
-    page('/admin/projects/q/filter::filter', loadUser, data => {
-      app.params = {
-        view: Gerrit.Nav.View.ADMIN,
-        adminView: 'gr-admin-project-list',
-        filter: data.params.filter || null,
-      };
-    });
-
-    // Matches /admin/projects/<project>
-    page(/^\/admin\/projects\/([^,]+)$/, loadUser, data => {
-      app.params = {
-        view: Gerrit.Nav.View.ADMIN,
-        project: data.params[0],
-        adminView: 'gr-project',
-      };
-    });
-
-    // Matches /admin/plugins[,<offset>][/].
-    page(/^\/admin\/plugins(,(\d+))?(\/)?$/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: data.params[1] || 0,
-            filter: null,
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page('/admin/plugins/q/filter::filter,:offset', loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            offset: data.params.offset,
-            filter: data.params.filter,
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page('/admin/plugins/q/filter::filter', loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-            filter: data.params.filter || null,
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page(/^\/admin\/plugins(\/)?$/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.ADMIN,
-            adminView: 'gr-plugin-list',
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page('/admin/(.*)', loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          data.params.view = Gerrit.Nav.View.ADMIN;
-          data.params.placeholder = true;
-          app.params = data.params;
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    function queryHandler(data) {
-      data.params.view = Gerrit.Nav.View.SEARCH;
-      app.params = data.params;
-    }
-
-    page('/q/:query,:offset', queryHandler);
-    page('/q/:query', queryHandler);
-
-    page(/^\/(\d+)\/?/, ctx => {
-      page.redirect('/c/' + encodeURIComponent(ctx.params[0]));
-    });
-
-    /**
-     * Normalizes the params object, and determines if the URL needs to be
-     * modified to fit the proper schema.
-     *
-     * @param {*} params
-     * @return {boolean} whether or not the URL needs to be upgraded.
-     */
-    const normalizePatchRangeParams = params => {
-      let needsRedirect = false;
-      if (params.basePatchNum &&
-          patchNumEquals(params.basePatchNum, params.patchNum)) {
-        needsRedirect = true;
-        params.basePatchNum = null;
-      } else if (params.basePatchNum && !params.patchNum) {
-        // Regexes set basePatchNum instead of patchNum when only one is
-        // specified. Redirect is not needed in this case.
-        params.patchNum = params.basePatchNum;
-        params.basePatchNum = null;
-      }
-      // In GWTUI, edits are represented in URLs with either 0 or 'edit'.
-      // TODO(kaspern): Remove this normalization when GWT UI is gone.
-      if (patchNumEquals(params.basePatchNum, 0)) {
-        params.basePatchNum = EDIT_NAME;
-        needsRedirect = true;
-      }
-      if (patchNumEquals(params.patchNum, 0)) {
-        params.patchNum = EDIT_NAME;
-        needsRedirect = true;
-      }
-      return needsRedirect;
-    };
-
-    // Matches
-    // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>]/[path].
-    // TODO(kaspern): Migrate completely to project based URLs, with backwards
-    // compatibility for change-only.
-    // eslint-disable-next-line max-len
-    page(/^\/c\/(.+)\/\+\/(\d+)(\/?((\d+|edit)(\.\.(\d+|edit))?(\/(.+))?))?\/?$/,
-        ctx => {
-          // Parameter order is based on the regex group number matched.
-          const params = {
-            project: ctx.params[0],
-            changeNum: ctx.params[1],
-            basePatchNum: ctx.params[4],
-            patchNum: ctx.params[6],
-            path: ctx.params[8],
-            view: ctx.params[8] ? Gerrit.Nav.View.DIFF : Gerrit.Nav.View.CHANGE,
-            hash: ctx.hash,
-          };
-          const needsRedirect = normalizePatchRangeParams(params);
-          if (needsRedirect) {
-            page.redirect(generateUrl(params));
-          } else {
-            app.params = params;
-            restAPI.setInProjectLookup(params.changeNum, params.project);
-          }
-        });
-
-    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-    page(/^\/c\/(\d+)\/?(((\d+|edit)(\.\.(\d+|edit))?))?\/?$/, ctx => {
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        changeNum: ctx.params[0],
-        basePatchNum: ctx.params[3],
-        patchNum: ctx.params[5],
-        view: Gerrit.Nav.View.CHANGE,
-      };
-
-      normalizeLegacyRouteParams(params);
-    });
-
-    // Matches /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-    page(/^\/c\/(\d+)\/((\d+|edit)(\.\.(\d+|edit))?)\/(.+)/, ctx => {
-      // Check if path has an '@' which indicates it was using GWT style line
-      // numbers. Even if the filename had an '@' in it, it would have already
-      // been URI encoded. Redirect to hash version of path.
-      if (ctx.path.includes('@')) {
-        page.redirect(ctx.path.replace('@', '#'));
-        return;
-      }
-
-      // Parameter order is based on the regex group number matched.
-      const params = {
-        changeNum: ctx.params[0],
-        basePatchNum: ctx.params[2],
-        patchNum: ctx.params[4],
-        path: ctx.params[5],
-        hash: ctx.hash,
-        view: Gerrit.Nav.View.DIFF,
-      };
-
-      normalizeLegacyRouteParams(params);
-    });
-
-    page(/^\/settings\/(agreements|new-agreement)/, loadUser, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          data.params.view = Gerrit.Nav.View.AGREEMENTS;
-          app.params = data.params;
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page(/^\/settings\/VE\/(\S+)/, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {
-            view: Gerrit.Nav.View.SETTINGS,
-            emailToken: data.params[0],
-          };
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page(/^\/settings\/?/, data => {
-      restAPI.getLoggedIn().then(loggedIn => {
-        if (loggedIn) {
-          app.params = {view: Gerrit.Nav.View.SETTINGS};
-        } else {
-          redirectToLogin(data.canonicalPath);
-        }
-      });
-    });
-
-    page(/^\/register(\/.*)?/, ctx => {
-      app.params = {justRegistered: true};
-      const path = ctx.params[0] || '/';
-      if (path[0] !== '/') { return; }
-      page.show(base + path);
-    });
-
-    page.start();
-  }
-
   Polymer({
     is: 'gr-router',
-    behaviors: [Gerrit.PatchSetBehavior],
+
+    properties: {
+      _restAPI: {
+        type: Object,
+        value: () => document.createElement('gr-rest-api-interface'),
+      },
+    },
+
+    behaviors: [
+      Gerrit.URLEncodingBehavior,
+      Gerrit.PatchSetBehavior,
+    ],
+
     start() {
       if (!app) { return; }
-      startRouter(this._generateUrl.bind(this));
+      this._startRouter();
+    },
+
+    _setParams(params) {
+      app.params = params;
+    },
+
+    _redirect(url) {
+      page.redirect(url);
     },
 
     _generateUrl(params) {
@@ -594,28 +72,30 @@
       if (params.view === Gerrit.Nav.View.SEARCH) {
         const operators = [];
         if (params.owner) {
-          operators.push('owner:' + encode(params.owner));
+          operators.push('owner:' + this.encodeURL(params.owner, false));
         }
         if (params.project) {
-          operators.push('project:' + encode(params.project));
+          operators.push('project:' + this.encodeURL(params.project, false));
         }
         if (params.branch) {
-          operators.push('branch:' + encode(params.branch));
+          operators.push('branch:' + this.encodeURL(params.branch, false));
         }
         if (params.topic) {
-          operators.push('topic:"' + encode(params.topic) + '"');
+          operators.push('topic:"' + this.encodeURL(params.topic, false) + '"');
         }
         if (params.hashtag) {
           operators.push('hashtag:"' +
-              encode(params.hashtag.toLowerCase()) + '"');
+              this.encodeURL(params.hashtag.toLowerCase(), false) + '"');
         }
         if (params.statuses) {
           if (params.statuses.length === 1) {
-            operators.push('status:' + encode(params.statuses[0]));
+            operators.push(
+                'status:' + this.encodeURL(params.statuses[0], false));
           } else if (params.statuses.length > 1) {
             operators.push(
                 '(' +
-                params.statuses.map(s => `status:${encode(s)}`).join(' OR ') +
+                params.statuses.map(s => `status:${this.encodeURL(s, false)}`)
+                    .join(' OR ') +
                 ')');
           }
         }
@@ -632,7 +112,7 @@
         let range = this._getPatchRangeExpression(params);
         if (range.length) { range = '/' + range; }
 
-        let suffix = `${range}/${encode(params.path, true)}`;
+        let suffix = `${range}/${this.encodeURL(params.path, true)}`;
         if (params.lineNum) {
           suffix += '#';
           if (params.leftSide) { suffix += 'b'; }
@@ -657,5 +137,552 @@
       if (params.basePatchNum) { range = params.basePatchNum + '..' + range; }
       return range;
     },
+
+    /**
+     * Given a set of params without a project, gets the project from the rest
+     * API project lookup and then sets the app params.
+     *
+     * @param {?Object} params
+     */
+    _normalizeLegacyRouteParams(params) {
+      if (!params.changeNum) { return Promise.resolve(); }
+
+      return this._restAPI.getFromProjectLookup(params.changeNum)
+          .then(project => {
+            params.project = project;
+            this._normalizePatchRangeParams(params);
+            this._redirect(this._generateUrl(params));
+          });
+    },
+
+    /**
+     * Normalizes the params object, and determines if the URL needs to be
+     * modified to fit the proper schema.
+     *
+     * @param {*} params
+     * @return {boolean} whether or not the URL needs to be upgraded.
+     */
+    _normalizePatchRangeParams(params) {
+      const hasBasePatchNum = params.basePatchNum !== null &&
+          params.basePatchNum !== undefined;
+      const hasPatchNum = params.patchNum !== null &&
+          params.patchNum !== undefined;
+      let needsRedirect = false;
+      if (hasBasePatchNum &&
+          this.patchNumEquals(params.basePatchNum, params.patchNum)) {
+        needsRedirect = true;
+        params.basePatchNum = null;
+      } else if (hasBasePatchNum && !hasPatchNum) {
+        // Regexes set basePatchNum instead of patchNum when only one is
+        // specified. Redirect is not needed in this case.
+        params.patchNum = params.basePatchNum;
+        params.basePatchNum = null;
+      }
+      // In GWTUI, edits are represented in URLs with either 0 or 'edit'.
+      // TODO(kaspern): Remove this normalization when GWT UI is gone.
+      if (this.patchNumEquals(params.basePatchNum, 0)) {
+        params.basePatchNum = this.EDIT_NAME;
+        needsRedirect = true;
+      }
+      if (this.patchNumEquals(params.patchNum, 0)) {
+        params.patchNum = this.EDIT_NAME;
+        needsRedirect = true;
+      }
+      return needsRedirect;
+    },
+
+    _redirectToLogin(data) {
+      const basePath = window.Gerrit.BaseUrlBehavior.getBaseUrl() || '';
+      page('/login/' + encodeURIComponent(data.substring(basePath.length)));
+    },
+
+    _startRouter() {
+      const base = window.Gerrit.BaseUrlBehavior.getBaseUrl();
+      if (base) {
+        page.base(base);
+      }
+
+      const reporting = getReporting();
+
+      Gerrit.Nav.setup(url => { page.show(url); },
+          this._generateUrl.bind(this));
+
+      // Middleware
+      page((ctx, next) => {
+        document.body.scrollTop = 0;
+
+        // Fire asynchronously so that the URL is changed by the time the event
+        // is processed.
+        this.async(() => {
+          app.fire('location-change', {
+            hash: window.location.hash,
+            pathname: window.location.pathname,
+          });
+          reporting.locationChanged();
+        }, 1);
+        next();
+      });
+
+      const loadUser = (ctx, next) => {
+        this._restAPI.getLoggedIn().then(() => { next(); });
+      };
+
+      // Routes.
+      page('/', loadUser, data => {
+        if (data.querystring.match(/^closeAfterLogin/)) {
+          // Close child window on redirect after login.
+          window.close();
+        }
+        // For backward compatibility with GWT links.
+        if (data.hash) {
+          // In certain login flows the server may redirect to a hash without
+          // a leading slash, which page.js doesn't handle correctly.
+          if (data.hash[0] !== '/') {
+            data.hash = '/' + data.hash;
+          }
+          if (data.hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+            // Path decodes all '+' to ' ' -- this breaks project-based URLs.
+            // See Issue 6888.
+            data.hash = data.hash.replace('/ /', '/+/');
+          }
+          const hash = data.hash;
+          let newUrl = base + hash;
+          if (hash.startsWith('/VE/')) {
+            newUrl = base + '/settings' + data.hash;
+          }
+          this._redirect(newUrl);
+          return;
+        }
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._redirect('/dashboard/self');
+          } else {
+            this._redirect('/q/status:open');
+          }
+        });
+      });
+
+      page('/dashboard/(.*)', loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            data.params.view = Gerrit.Nav.View.DASHBOARD;
+            this._setParams(data.params);
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      // Matches /admin/groups/<group>,info (backwords compat with gwtui)
+      // Redirects to /admin/groups/<group>
+      page(/^\/admin\/groups\/(.+),info$/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._redirect(
+                '/admin/groups/' + encodeURIComponent(data.params[0]));
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      // Matches /admin/groups/<group>,audit-log
+      page(/^\/admin\/groups\/(.+),audit-log$/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-group-audit-log',
+              detailType: 'audit-log',
+              groupId: data.params[0],
+            });
+          } else {
+            this._redirect('/login/' + encodeURIComponent(data.canonicalPath));
+          }
+        });
+      });
+
+      // Matches /admin/groups/<group>,members
+      page(/^\/admin\/groups\/(.+),members$/, loadUser, data => {
+        this._setParams({
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-group-members',
+          detailType: 'members',
+          groupId: data.params[0],
+        });
+      });
+
+      // Matches /admin/groups[,<offset>][/].
+      page(/^\/admin\/groups(,(\d+))?(\/)?$/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-admin-group-list',
+              offset: data.params[1] || 0,
+              filter: null,
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page('/admin/groups/q/filter::filter,:offset', loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-admin-group-list',
+              offset: data.params.offset,
+              filter: data.params.filter,
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page('/admin/groups/q/filter::filter', loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-admin-group-list',
+              filter: data.params.filter || null,
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      // Matches /admin/groups/<group>
+      page(/^\/admin\/groups\/([^,]+)$/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-group',
+              groupId: data.params[0],
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      // Matches /admin/projects/<project>,commands.
+      page(/^\/admin\/projects\/(.+),commands$/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-commands',
+              detailType: 'commands',
+              project: data.params[0],
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      // Matches /admin/projects/<project>,branches[,<offset>].
+      page(/^\/admin\/projects\/(.+),branches(,(.+))?$/, loadUser, data => {
+        this._setParams({
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-project-detail-list',
+          detailType: 'branches',
+          project: data.params[0],
+          offset: data.params[2] || 0,
+          filter: null,
+        });
+      });
+
+      page('/admin/projects/:project,branches/q/filter::filter,:offset',
+          loadUser, data => {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'branches',
+              project: data.params.project,
+              offset: data.params.offset,
+              filter: data.params.filter,
+            });
+          });
+
+      page('/admin/projects/:project,branches/q/filter::filter',
+          loadUser, data => {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'branches',
+              project: data.params.project,
+              filter: data.params.filter || null,
+            });
+          });
+
+      // Matches /admin/projects/<project>,tags[,<offset>].
+      page(/^\/admin\/projects\/(.+),tags(,(.+))?$/, loadUser, data => {
+        this._setParams({
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-project-detail-list',
+          detailType: 'tags',
+          project: data.params[0],
+          offset: data.params[2] || 0,
+          filter: null,
+        });
+      });
+
+      page('/admin/projects/:project,tags/q/filter::filter,:offset',
+          loadUser, data => {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'tags',
+              project: data.params.project,
+              offset: data.params.offset,
+              filter: data.params.filter,
+            });
+          });
+
+      page('/admin/projects/:project,tags/q/filter::filter',
+          loadUser, data => {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'tags',
+              project: data.params.project,
+              filter: data.params.filter || null,
+            });
+          });
+
+      // Matches /admin/projects[,<offset>][/].
+      page(/^\/admin\/projects(,(\d+))?(\/)?$/, loadUser, data => {
+        this._setParams({
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-project-list',
+          offset: data.params[1] || 0,
+          filter: null,
+        });
+      });
+
+      page('/admin/projects/q/filter::filter,:offset', loadUser, data => {
+        this._setParams({
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-project-list',
+          offset: data.params.offset,
+          filter: data.params.filter,
+        });
+      });
+
+      page('/admin/projects/q/filter::filter', loadUser, data => {
+        this._setParams({
+          view: Gerrit.Nav.View.ADMIN,
+          adminView: 'gr-admin-project-list',
+          filter: data.params.filter || null,
+        });
+      });
+
+      // Matches /admin/projects/<project>
+      page(/^\/admin\/projects\/([^,]+)$/, loadUser, data => {
+        this._setParams({
+          view: Gerrit.Nav.View.ADMIN,
+          project: data.params[0],
+          adminView: 'gr-project',
+        });
+      });
+
+      // Matches /admin/plugins[,<offset>][/].
+      page(/^\/admin\/plugins(,(\d+))?(\/)?$/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-plugin-list',
+              offset: data.params[1] || 0,
+              filter: null,
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page('/admin/plugins/q/filter::filter,:offset', loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-plugin-list',
+              offset: data.params.offset,
+              filter: data.params.filter,
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page('/admin/plugins/q/filter::filter', loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-plugin-list',
+              filter: data.params.filter || null,
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page(/^\/admin\/plugins(\/)?$/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-plugin-list',
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page('/admin/(.*)', loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            data.params.view = Gerrit.Nav.View.ADMIN;
+            data.params.placeholder = true;
+            this._setParams(data.params);
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      const queryHandler = data => {
+        data.params.view = Gerrit.Nav.View.SEARCH;
+        this._setParams(data.params);
+      };
+
+      page('/q/:query,:offset', queryHandler);
+      page('/q/:query', queryHandler);
+
+      page(/^\/(\d+)\/?/, ctx => {
+        this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+      });
+
+      // Matches
+      // /c/<project>/+/<changeNum>/
+      //     [<basePatchNum|edit>..][<patchNum|edit>]/[path].
+      // TODO(kaspern): Migrate completely to project based URLs, with backwards
+      // compatibility for change-only.
+      // eslint-disable-next-line max-len
+      page(/^\/c\/(.+)\/\+\/(\d+)(\/?((\d+|edit)(\.\.(\d+|edit))?(\/(.+))?))?\/?$/,
+          ctx => {
+            // Parameter order is based on the regex group number matched.
+            const params = {
+              project: ctx.params[0],
+              changeNum: ctx.params[1],
+              basePatchNum: ctx.params[4],
+              patchNum: ctx.params[6],
+              path: ctx.params[8],
+              view: ctx.params[8] ?
+                Gerrit.Nav.View.DIFF : Gerrit.Nav.View.CHANGE,
+              hash: ctx.hash,
+            };
+            const needsRedirect = this._normalizePatchRangeParams(params);
+            if (needsRedirect) {
+              this._redirect(this._generateUrl(params));
+            } else {
+              this._setParams(params);
+              this._restAPI.setInProjectLookup(params.changeNum,
+                  params.project);
+            }
+          });
+
+      // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
+      page(/^\/c\/(\d+)\/?(((\d+|edit)(\.\.(\d+|edit))?))?\/?$/, ctx => {
+        // Parameter order is based on the regex group number matched.
+        const params = {
+          changeNum: ctx.params[0],
+          basePatchNum: ctx.params[3],
+          patchNum: ctx.params[5],
+          view: Gerrit.Nav.View.CHANGE,
+        };
+
+        this._normalizeLegacyRouteParams(params);
+      });
+
+      // Matches /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
+      page(/^\/c\/(\d+)\/((\d+|edit)(\.\.(\d+|edit))?)\/(.+)/, ctx => {
+        // Check if path has an '@' which indicates it was using GWT style line
+        // numbers. Even if the filename had an '@' in it, it would have already
+        // been URI encoded. Redirect to hash version of path.
+        if (ctx.path.includes('@')) {
+          this._redirect(ctx.path.replace('@', '#'));
+          return;
+        }
+
+        // Parameter order is based on the regex group number matched.
+        const params = {
+          changeNum: ctx.params[0],
+          basePatchNum: ctx.params[2],
+          patchNum: ctx.params[4],
+          path: ctx.params[5],
+          hash: ctx.hash,
+          view: Gerrit.Nav.View.DIFF,
+        };
+
+        this._normalizeLegacyRouteParams(params);
+      });
+
+      page(/^\/settings\/(agreements|new-agreement)/, loadUser, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            data.params.view = Gerrit.Nav.View.AGREEMENTS;
+            this._setParams(data.params);
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page(/^\/settings\/VE\/(\S+)/, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({
+              view: Gerrit.Nav.View.SETTINGS,
+              emailToken: data.params[0],
+            });
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page(/^\/settings\/?/, data => {
+        this._restAPI.getLoggedIn().then(loggedIn => {
+          if (loggedIn) {
+            this._setParams({view: Gerrit.Nav.View.SETTINGS});
+          } else {
+            this._redirectToLogin(data.canonicalPath);
+          }
+        });
+      });
+
+      page(/^\/register(\/.*)?/, ctx => {
+        this._setParams({justRegistered: true});
+        const path = ctx.params[0] || '/';
+        if (path[0] !== '/') { return; }
+        page.show(base + path);
+      });
+
+      page.start();
+    },
   });
 })();
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 d1392ee..51fcfc1 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
@@ -33,13 +33,17 @@
 
 <script>
   suite('gr-router tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
     suite('generateUrl', () => {
-      let element;
-
-      setup(() => {
-        element = fixture('basic');
-      });
-
       test('search', () => {
         let params = {
           view: Gerrit.Nav.View.SEARCH,
@@ -109,6 +113,104 @@
         assert.equal(element._generateUrl(params),
             '/c/test/+/42/2/file.cpp#b123');
       });
+
+      test('_getPatchRangeExpression', () => {
+        const params = {};
+        let actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '');
+
+        params.patchNum = 4;
+        actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '4');
+
+        params.basePatchNum = 2;
+        actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '2..4');
+
+        delete params.patchNum;
+        actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '2..');
+      });
+    });
+
+    suite('param normalization', () => {
+      let projectLookupStub;
+
+      setup(() => {
+        projectLookupStub = sandbox
+            .stub(element._restAPI, 'getFromProjectLookup')
+            .returns(Promise.resolve('foo/bar'));
+        sandbox.stub(element, '_generateUrl');
+      });
+
+      suite('_normalizeLegacyRouteParams', () => {
+        let rangeStub;
+
+        setup(() => {
+          rangeStub = sandbox.stub(element, '_normalizePatchRangeParams')
+              .returns(Promise.resolve());
+        });
+
+        test('w/o changeNum', () => {
+          const params = {};
+          return element._normalizeLegacyRouteParams(params).then(() => {
+            assert.isFalse(projectLookupStub.called);
+            assert.isFalse(rangeStub.called);
+            assert.isNotOk(params.project);
+          });
+        });
+
+        test('w/ changeNum', () => {
+          const params = {changeNum: 1234};
+          return element._normalizeLegacyRouteParams(params).then(() => {
+            assert.isTrue(projectLookupStub.called);
+            assert.isTrue(rangeStub.called);
+            assert.equal(params.project, 'foo/bar');
+          });
+        });
+      });
+
+      suite('_normalizePatchRangeParams', () => {
+        test('range n..n normalizes to n', () => {
+          const params = {basePatchNum: 4, patchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 4);
+        });
+
+        test('range n.. normalizes to n', () => {
+          const params = {basePatchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isFalse(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 4);
+        });
+
+        test('range 0..n normalizes to edit..n', () => {
+          const params = {basePatchNum: 0, patchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.equal(params.basePatchNum, 'edit');
+          assert.equal(params.patchNum, 4);
+        });
+
+        test('range n..0 normalizes to n..edit', () => {
+          const params = {basePatchNum: 4, patchNum: 0};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.equal(params.basePatchNum, 4);
+          assert.equal(params.patchNum, 'edit');
+        });
+
+        test('range 0..0 normalizes to edit', () => {
+          const params = {basePatchNum: 0, patchNum: 0};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 'edit');
+        });
+      });
     });
   });
 </script>