Merge "Upgrade protobuf-java to 3.4.0"
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 9743283..150bf88 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -403,6 +403,18 @@
 notedb.accounts.sequenceBatchSize] parameter in the `gerrit.config`
 file.
 
+[[replication]]
+== Replication
+
+To replicate account data the following branches from the `All-Users`
+repository must be replicated:
+
+* `refs/users/*` (user branches)
+* `refs/meta/external-ids` (external IDs)
+* `refs/starred-changes/*` (star labels)
+* `refs/sequences/accounts` (account sequence numbers, not needed for Gerrit
+  slaves)
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/database-setup.txt b/Documentation/database-setup.txt
index 0788cb8..fc43b97 100644
--- a/Documentation/database-setup.txt
+++ b/Documentation/database-setup.txt
@@ -68,7 +68,7 @@
   mysql
 
   CREATE USER 'gerrit'@'localhost' IDENTIFIED BY 'secret';
-  CREATE DATABASE reviewdb;
+  CREATE DATABASE reviewdb DEFAULT CHARACTER SET 'utf8';
   GRANT ALL ON reviewdb.* TO 'gerrit'@'localhost';
   FLUSH PRIVILEGES;
 ----
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 1a026d1..983e5e2 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -407,6 +407,10 @@
 +
 Update of the group secondary index
 
+* `com.google.gerrit.server.extensions.events.ProjectIndexedListener`:
++
+Update of the project secondary index
+
 * `com.google.gerrit.httpd.WebLoginListener`:
 +
 User login or logout interactively on the Web user interface.
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 9d76d34..6e256f1 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -324,6 +324,65 @@
   }
 ----
 
+[[query-projects]]
+=== Query Projects
+--
+'GET /projects/?query=<query>'
+--
+
+Queries projects visible to the caller. The
+link:user-search-projects.html#_search_operators[query string] must be
+provided by the `query` parameter. The `start` and `limit` parameters
+can be used to skip/limit results.
+
+As result a list of link:#project-info[ProjectInfo] entities is returned.
+
+.Request
+----
+  GET /projects/?query=name:test HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "test": {
+      "id": "test",
+      "description": "\u003chtml\u003e is escaped"
+    }
+  }
+----
+
+[[project-query-limit]]
+==== Project Limit
+The `/projects/?query=<query>` URL also accepts a limit integer in the
+`limit` parameter. This limits the results to `limit` projects.
+
+Query the first 25 projects in project list.
+----
+  GET /projects/?query=<query>&limit=25 HTTP/1.0
+----
+
+The `/projects/` URL also accepts a start integer in the `start`
+parameter. The results will skip `start` groups from project list.
+
+Query 25 projects starting from index 50.
+----
+  GET /groups/?query=<query>&limit=25&start=50 HTTP/1.0
+----
+
+[[project-query-options]]
+==== Project Options
+Additional fields can be obtained by adding `o` parameters. Each option
+requires more lookups and slows down the query response time to the
+client so they are generally disabled by default. The supported fields
+are described in the context of the link:#project-options[List Projects]
+REST endpoint.
+
 [[get-project]]
 === Get Project
 --
diff --git a/Documentation/user-search-projects.txt b/Documentation/user-search-projects.txt
new file mode 100644
index 0000000..eff64fa
--- /dev/null
+++ b/Documentation/user-search-projects.txt
@@ -0,0 +1,36 @@
+= Gerrit Code Review - Searching Projects
+
+[[search-operators]]
+== Search Operators
+
+Operators act as restrictions on the search. As more operators
+are added to the same query string, they further restrict the
+returned results.
+
+[[name]]
+name:'NAME'::
++
+Matches projects that have the NAME 'NAME'.
+
+== Magical Operators
+
+[[is-visible]]
+is:visible::
++
+Magical internal flag to prove the current user has access to read
+the projects and all the refs. This flag is always added to any query.
+
+[[limit]]
+limit:'CNT'::
++
+Limit the returned results to no more than 'CNT' records. This is
+automatically set to the page size configured in the current user's
+preferences. Including it in a web query may lead to unpredictable
+results with regards to pagination.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/WORKSPACE b/WORKSPACE
index 58ee261..d0d797f 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1,5 +1,9 @@
 workspace(name = "gerrit")
 
+load("//:version.bzl", "check_version")
+
+check_version("0.5.3")
+
 load("//tools/bzl:maven_jar.bzl", "maven_jar", "GERRIT", "MAVEN_LOCAL")
 load("//lib/codemirror:cm.bzl", "CM_VERSION", "DIFF_MATCH_PATCH_VERSION")
 load("//plugins:external_plugin_deps.bzl", "external_plugin_deps")
@@ -175,8 +179,8 @@
 
 maven_jar(
     name = "gson",
-    artifact = "com.google.code.gson:gson:2.8.0",
-    sha1 = "c4ba5371a29ac9b2ad6129b1d39ea38750043eff",
+    artifact = "com.google.code.gson:gson:2.8.2",
+    sha1 = "3edcfe49d2c6053a70a2a47e4e1c2f94998a49cf",
 )
 
 maven_jar(
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 84f3533..22d27ac 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
@@ -38,6 +38,7 @@
 
 import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Iterables;
@@ -67,6 +68,7 @@
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -79,6 +81,8 @@
 import com.google.gerrit.gpg.testutil.TestKey;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountConfig;
@@ -106,6 +110,7 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
@@ -155,6 +160,8 @@
 
   @Inject private DynamicSet<AccountIndexedListener> accountIndexedListeners;
 
+  @Inject private DynamicSet<GitReferenceUpdatedListener> refUpdateListeners;
+
   @Inject private Sequences seq;
 
   @Inject private Provider<InternalAccountQuery> accountQueryProvider;
@@ -163,6 +170,8 @@
 
   private AccountIndexedCounter accountIndexedCounter;
   private RegistrationHandle accountIndexEventCounterHandle;
+  private RefUpdateCounter refUpdateCounter;
+  private RegistrationHandle refUpdateCounterHandle;
   private ExternalIdsUpdate externalIdsUpdate;
   private List<ExternalId> savedExternalIds;
 
@@ -180,6 +189,19 @@
   }
 
   @Before
+  public void addRefUpdateCounter() {
+    refUpdateCounter = new RefUpdateCounter();
+    refUpdateCounterHandle = refUpdateListeners.add(refUpdateCounter);
+  }
+
+  @After
+  public void removeRefUpdateCounter() {
+    if (refUpdateCounterHandle != null) {
+      refUpdateCounterHandle.remove();
+    }
+  }
+
+  @Before
   public void saveExternalIds() throws Exception {
     externalIdsUpdate = externalIdsUpdateFactory.create();
 
@@ -228,16 +250,29 @@
 
   @Test
   public void create() throws Exception {
-    create(2); // account creation + external ID creation
+    Account.Id accountId = create(2); // account creation + external ID creation
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+        RefUpdateCounter.projectRef(allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS));
   }
 
   @Test
   @UseSsh
   public void createWithSshKeys() throws Exception {
-    create(3); // account creation + external ID creation + adding SSH keys
+    Account.Id accountId = create(3); // account creation + external ID creation + adding SSH keys
+    refUpdateCounter.assertRefUpdateFor(
+        ImmutableMap.of(
+            RefUpdateCounter.projectRef(allUsers, RefNames.refsUsers(accountId)),
+            2,
+            RefUpdateCounter.projectRef(allUsers, RefNames.REFS_EXTERNAL_IDS),
+            1,
+            RefUpdateCounter.projectRef(
+                allUsers, RefNames.REFS_SEQUENCES + Sequences.NAME_ACCOUNTS),
+            1));
   }
 
-  private void create(int expectedAccountReindexCalls) throws Exception {
+  private Account.Id create(int expectedAccountReindexCalls) throws Exception {
     String name = "foo";
     TestAccount foo = accountCreator.create(name);
     AccountInfo info = gApi.accounts().id(foo.id.get()).get();
@@ -245,6 +280,7 @@
     assertThat(info.name).isEqualTo(name);
     accountIndexedCounter.assertReindexOf(foo, expectedAccountReindexCalls);
     assertUserBranch(foo.getId(), name, null);
+    return foo.getId();
   }
 
   @Test
@@ -378,15 +414,24 @@
   public void starUnstarChange() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
+    refUpdateCounter.clear();
+
     gApi.accounts().self().starChange(triplet);
     ChangeInfo change = info(triplet);
     assertThat(change.starred).isTrue();
     assertThat(change.stars).contains(DEFAULT_LABEL);
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
 
     gApi.accounts().self().unstarChange(triplet);
     change = info(triplet);
     assertThat(change.starred).isNull();
     assertThat(change.stars).isNull();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
     accountIndexedCounter.assertNoReindex();
   }
 
@@ -394,6 +439,8 @@
   public void starUnstarChangeWithLabels() throws Exception {
     PushOneCommit.Result r = createChange();
     String triplet = project.get() + "~master~" + r.getChangeId();
+    refUpdateCounter.clear();
+
     assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
     assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
 
@@ -412,6 +459,9 @@
     assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
     assertThat(starredChange.starred).isTrue();
     assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
 
     gApi.accounts()
         .self()
@@ -428,6 +478,10 @@
     assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
     assertThat(starredChange.starred).isNull();
     assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
+    refUpdateCounter.assertRefUpdateFor(
+        RefUpdateCounter.projectRef(
+            allUsers, RefNames.refsStarredChanges(new Change.Id(change._number), admin.id)));
+
     accountIndexedCounter.assertNoReindex();
 
     setApiUser(user);
@@ -1899,4 +1953,45 @@
       assertThat(countsByAccount).isEmpty();
     }
   }
+
+  private static class RefUpdateCounter implements GitReferenceUpdatedListener {
+    private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
+
+    static String projectRef(Project.NameKey project, String ref) {
+      return projectRef(project.get(), ref);
+    }
+
+    static String projectRef(String project, String ref) {
+      return project + ":" + ref;
+    }
+
+    @Override
+    public void onGitReferenceUpdated(Event event) {
+      countsByProjectRefs.incrementAndGet(projectRef(event.getProjectName(), event.getRefName()));
+    }
+
+    void clear() {
+      countsByProjectRefs.clear();
+    }
+
+    long getCount(String projectRef) {
+      return countsByProjectRefs.get(projectRef);
+    }
+
+    void assertRefUpdateFor(String... projectRefs) {
+      Map<String, Integer> expectedRefUpdateCounts = new HashMap<>();
+      for (String projectRef : projectRefs) {
+        expectedRefUpdateCounts.put(projectRef, 1);
+      }
+      assertRefUpdateFor(expectedRefUpdateCounts);
+    }
+
+    void assertRefUpdateFor(Map<String, Integer> expectedProjectRefUpdateCounts) {
+      for (Map.Entry<String, Integer> e : expectedProjectRefUpdateCounts.entrySet()) {
+        assertThat(getCount(e.getKey())).isEqualTo(e.getValue());
+      }
+      assertThat(countsByProjectRefs).hasSize(expectedProjectRefUpdateCounts.size());
+      clear();
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c2d3184..baa0a68 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -41,6 +41,7 @@
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
+import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -57,6 +58,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.GerritConfig;
@@ -105,6 +107,7 @@
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
+import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -170,6 +173,11 @@
 
   @Inject private DynamicSet<ChangeMessageModifier> changeMessageModifiers;
 
+  @Inject private DynamicSet<ChangeIndexedListener> changeIndexedListeners;
+
+  private ChangeIndexedCounter changeIndexedCounter;
+  private RegistrationHandle changeIndexedCounterHandle;
+
   @Before
   public void setTimeForTesting() {
     systemTimeZone = System.setProperty("user.timezone", "US/Eastern");
@@ -181,6 +189,19 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
+  @Before
+  public void addChangeIndexedCounter() {
+    changeIndexedCounter = new ChangeIndexedCounter();
+    changeIndexedCounterHandle = changeIndexedListeners.add(changeIndexedCounter);
+  }
+
+  @After
+  public void removeChangeIndexedCounter() {
+    if (changeIndexedCounterHandle != null) {
+      changeIndexedCounterHandle.remove();
+    }
+  }
+
   @Test
   public void reflog() throws Exception {
     // Tests are using DfsRepository which does not implement getReflogReader,
@@ -3299,6 +3320,25 @@
   }
 
   @Test
+  public void starUnstar() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String triplet = project.get() + "~master~" + r.getChangeId();
+    changeIndexedCounter.clear();
+
+    gApi.accounts().self().starChange(triplet);
+    ChangeInfo change = info(triplet);
+    assertThat(change.starred).isTrue();
+    assertThat(change.stars).contains(DEFAULT_LABEL);
+    changeIndexedCounter.assertReindexOf(change);
+
+    gApi.accounts().self().unstarChange(triplet);
+    change = info(triplet);
+    assertThat(change.starred).isNull();
+    assertThat(change.stars).isNull();
+    changeIndexedCounter.assertReindexOf(change);
+  }
+
+  @Test
   public void ignore() throws Exception {
     TestAccount user2 = accountCreator.user2();
 
@@ -3480,4 +3520,36 @@
     exception.expectMessage("invalid labels: " + invalidLabel);
     gApi.accounts().self().setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel)));
   }
+
+  private static class ChangeIndexedCounter implements ChangeIndexedListener {
+    private final AtomicLongMap<Integer> countsByChange = AtomicLongMap.create();
+
+    @Override
+    public void onChangeIndexed(int id) {
+      countsByChange.incrementAndGet(id);
+    }
+
+    @Override
+    public void onChangeDeleted(int id) {
+      countsByChange.incrementAndGet(id);
+    }
+
+    void clear() {
+      countsByChange.clear();
+    }
+
+    long getCount(ChangeInfo info) {
+      return countsByChange.get(info._number);
+    }
+
+    void assertReindexOf(ChangeInfo info) {
+      assertReindexOf(info, 1);
+    }
+
+    void assertReindexOf(ChangeInfo info, int expectedCount) {
+      assertThat(getCount(info)).isEqualTo(expectedCount);
+      assertThat(countsByChange).hasSize(1);
+      clear();
+    }
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 0fa09af..9b12069f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.extensions.api.plugins.Plugins.ListRequest;
 import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -45,7 +46,7 @@
   private static final RawInput HTML_PLUGIN_CONTENT =
       RawInputUtil.create(HTML_PLUGIN.getBytes(UTF_8));
 
-  private static final List<String> PLUGINS =
+  private static final ImmutableList<String> PLUGINS =
       ImmutableList.of(
           "plugin-a.js", "plugin-b.html", "plugin-c.js", "plugin-d.html", "plugin_e.js");
 
@@ -107,12 +108,21 @@
     api = gApi.plugins().name("plugin-a");
     assertThat(api.get().disabled).isNull();
     assertPlugins(list().get(), PLUGINS);
+
+    // Non-admin cannot disable
+    setApiUser(user);
+    try {
+      gApi.plugins().name("plugin-a").disable();
+      fail("Expected AuthException");
+    } catch (AuthException expected) {
+      // Expected
+    }
   }
 
   @Test
   public void installNotAllowed() throws Exception {
     exception.expect(MethodNotAllowedException.class);
-    exception.expectMessage("remote installation is disabled");
+    exception.expectMessage("remote plugin administration is disabled");
     gApi.plugins().install("test.js", new InstallPluginInput());
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
index 7d5072a..6f4495e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -18,11 +18,13 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -57,14 +59,24 @@
 
   @Test
   public void getDashboard() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     DashboardInfo result = project().dashboard(info.id).get();
     assertDashboardInfo(result, info);
   }
 
   @Test
+  public void getDashboardWithNoDescription() throws Exception {
+    DashboardInfo info = newDashboardInfo(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    info.description = null;
+    DashboardInfo created = createDashboard(info);
+    assertThat(created.description).isNull();
+    DashboardInfo result = project().dashboard(created.id).get();
+    assertThat(result.description).isNull();
+  }
+
+  @Test
   public void getDashboardNonDefault() throws Exception {
-    DashboardInfo info = createDashboard("my", "test");
+    DashboardInfo info = createTestDashboard("my", "test");
     DashboardInfo result = project().dashboard(info.id).get();
     assertDashboardInfo(result, info);
   }
@@ -72,15 +84,15 @@
   @Test
   public void listDashboards() throws Exception {
     assertThat(dashboards()).isEmpty();
-    DashboardInfo info1 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
-    DashboardInfo info2 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
+    DashboardInfo info1 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
+    DashboardInfo info2 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
     assertThat(dashboards().stream().map(d -> d.id).collect(toList()))
         .containsExactly(info1.id, info2.id);
   }
 
   @Test
   public void setDefaultDashboard() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().dashboard(info.id).setDefault();
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
@@ -89,7 +101,7 @@
 
   @Test
   public void setDefaultDashboardByProject() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     assertThat(info.isDefault).isNull();
     project().defaultDashboard(info.id);
     assertThat(project().dashboard(info.id).get().isDefault).isTrue();
@@ -104,8 +116,8 @@
 
   @Test
   public void replaceDefaultDashboard() throws Exception {
-    DashboardInfo d1 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
-    DashboardInfo d2 = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
+    DashboardInfo d1 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test1");
+    DashboardInfo d2 = createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test2");
     assertThat(d1.isDefault).isNull();
     assertThat(d2.isDefault).isNull();
     project().dashboard(d1.id).setDefault();
@@ -120,7 +132,7 @@
 
   @Test
   public void cannotGetDashboardWithInheritedForNonDefault() throws Exception {
-    DashboardInfo info = createDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+    DashboardInfo info = createTestDashboard();
     exception.expect(BadRequestException.class);
     exception.expectMessage("inherited flag can only be used with default");
     project().dashboard(info.id).get(true);
@@ -132,6 +144,14 @@
     assertThat(actual.ref).isEqualTo(expected.ref);
     assertThat(actual.project).isEqualTo(project.get());
     assertThat(actual.definingProject).isEqualTo(project.get());
+    assertThat(actual.description).isEqualTo(expected.description);
+    assertThat(actual.title).isEqualTo(expected.title);
+    assertThat(actual.foreach).isEqualTo(expected.foreach);
+    if (expected.sections == null) {
+      assertThat(actual.sections).isNull();
+    } else {
+      assertThat(actual.sections.size()).isEqualTo(expected.sections.size());
+    }
   }
 
   private List<DashboardInfo> dashboards() throws Exception {
@@ -142,8 +162,27 @@
     return gApi.projects().name(project.get());
   }
 
-  private DashboardInfo createDashboard(String ref, String path) throws Exception {
+  private DashboardInfo newDashboardInfo(String ref, String path) {
     DashboardInfo info = DashboardsCollection.newDashboardInfo(ref, path);
+    info.title = "Reviewer";
+    info.description = "Own review requests";
+    info.foreach = "owner:self";
+    DashboardSectionInfo section = new DashboardSectionInfo();
+    section.name = "Open";
+    section.query = "is:open";
+    info.sections = ImmutableList.of(section);
+    return info;
+  }
+
+  private DashboardInfo createTestDashboard() throws Exception {
+    return createTestDashboard(DashboardsCollection.DEFAULT_DASHBOARD_NAME, "test");
+  }
+
+  private DashboardInfo createTestDashboard(String ref, String path) throws Exception {
+    return createDashboard(newDashboardInfo(ref, path));
+  }
+
+  private DashboardInfo createDashboard(DashboardInfo info) throws Exception {
     String canonicalRef = DashboardsCollection.normalizeDashboardRef(info.ref);
     try {
       project().branch(canonicalRef).create(new BranchInput());
@@ -156,14 +195,23 @@
     try (Repository r = repoManager.openRepository(project)) {
       TestRepository<Repository>.CommitBuilder cb =
           new TestRepository<>(r).branch(canonicalRef).commit();
-      String content =
-          "[dashboard]\n"
-              + "Title = Reviewer\n"
-              + "Description = Own review requests\n"
-              + "foreach = owner:self\n"
-              + "[section \"Open\"]\n"
-              + "query = is:open";
-      cb.add(info.path, content);
+      StringBuilder content = new StringBuilder("[dashboard]\n");
+      if (info.title != null) {
+        content.append("title = ").append(info.title).append("\n");
+      }
+      if (info.description != null) {
+        content.append("description = ").append(info.description).append("\n");
+      }
+      if (info.foreach != null) {
+        content.append("foreach = ").append(info.foreach).append("\n");
+      }
+      if (info.sections != null) {
+        for (DashboardSectionInfo section : info.sections) {
+          content.append("[section \"").append(section.name).append("\"]\n");
+          content.append("query = ").append(section.query).append("\n");
+        }
+      }
+      cb.add(info.path, content.toString());
       RevCommit c = cb.create();
       project().commit(c.name());
     }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 95e4e16..928cd7e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1852,6 +1852,16 @@
         .isEqualTo(Iterables.getLast(commits).name());
   }
 
+  @Test
+  public void pushToPublishMagicBranchIsAllowed() throws Exception {
+    // Push to "refs/publish/*" will be a synonym of "refs/for/*".
+    createChange("refs/publish/master");
+    PushOneCommit.Result result = pushTo("refs/publish/master");
+    result.assertOkStatus();
+    assertThat(result.getMessage())
+        .endsWith("Pushing to refs/publish/* is deprecated, use refs/for/* instead.\n");
+  }
+
   private DraftInput newDraft(String path, int line, String message) {
     DraftInput d = new DraftInput();
     d.path = path;
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 2fe9dcd..4c4bc94 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate.RefsMetaExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gson.reflect.TypeToken;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -579,7 +580,17 @@
       noteMap.set(noteId, dataBlob);
 
       ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
       return noteId.getName();
     }
   }
@@ -600,7 +611,17 @@
       noteMap.set(noteId, dataBlob);
 
       ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
       return noteId.getName();
     }
   }
@@ -617,7 +638,17 @@
       noteMap.set(noteId, dataBlob);
 
       ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
       return noteId.getName();
     }
   }
@@ -634,7 +665,17 @@
       noteMap.set(noteId, dataBlob);
 
       ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, "Add external ID", admin.getIdent(), admin.getIdent());
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "Add external ID",
+          admin.getIdent(),
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
       return noteId.getName();
     }
   }
@@ -690,6 +731,8 @@
             new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
+            null,
+            GitReferenceUpdated.DISABLED,
             () -> {
               if (!doneBgUpdate.getAndSet(true)) {
                 try {
@@ -726,6 +769,8 @@
             new DisabledExternalIdCache(),
             serverIdent.get(),
             serverIdent.get(),
+            null,
+            GitReferenceUpdated.DISABLED,
             () -> {
               try {
                 extIdsUpdate
@@ -824,7 +869,17 @@
       NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
       ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
       ExternalIdsUpdate.commit(
-          repo, rw, ins, rev, noteMap, "insert new ID", serverIdent.get(), serverIdent.get());
+          allUsers,
+          repo,
+          rw,
+          ins,
+          rev,
+          noteMap,
+          "insert new ID",
+          serverIdent.get(),
+          serverIdent.get(),
+          null,
+          GitReferenceUpdated.DISABLED);
     }
   }
 
@@ -839,6 +894,7 @@
       }
 
       ExternalIdsUpdate.commit(
+          allUsers,
           testRepo.getRepository(),
           testRepo.getRevWalk(),
           ins,
@@ -846,7 +902,9 @@
           noteMap,
           "Add external ID",
           admin.getIdent(),
-          admin.getIdent());
+          admin.getIdent(),
+          null,
+          GitReferenceUpdated.DISABLED);
     }
   }
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index 7de9d70..c02b60f 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -18,13 +18,13 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.server.account.PutUsername;
+import com.google.gerrit.extensions.common.UsernameInput;
 import org.junit.Test;
 
 public class PutUsernameIT extends AbstractDaemonTest {
   @Test
   public void set() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
+    UsernameInput in = new UsernameInput();
     in.username = "myUsername";
     RestResponse r =
         adminRestSession.put("/accounts/" + accountCreator.create().id.get() + "/username", in);
@@ -34,7 +34,7 @@
 
   @Test
   public void setExisting_Conflict() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
+    UsernameInput in = new UsernameInput();
     in.username = admin.username;
     adminRestSession
         .put("/accounts/" + accountCreator.create().id.get() + "/username", in)
@@ -43,7 +43,7 @@
 
   @Test
   public void setNew_MethodNotAllowed() throws Exception {
-    PutUsername.Input in = new PutUsername.Input();
+    UsernameInput in = new UsernameInput();
     in.username = "newUsername";
     adminRestSession.put("/accounts/" + admin.username + "/username", in).assertMethodNotAllowed();
   }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index 0324ffa..e60abc6 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -43,6 +43,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -326,7 +327,7 @@
   public void restApiNotFoundWhenNoteDbDisabled() throws Exception {
     PushOneCommit.Result r = createChange();
     exception.expect(ResourceNotFoundException.class);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
   }
 
   @Test
@@ -336,7 +337,7 @@
     setNotesMigration(true, false);
 
     checker.assertNoChangeRef(project, id);
-    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Rebuild.Input());
+    rebuildHandler.apply(parseChangeResource(r.getChangeId()), new Input());
     checker.checkChanges(id);
   }
 
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
index 7868443..2d04e11 100644
--- a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -75,6 +76,10 @@
         new FactoryModuleBuilder()
             .implement(GroupIndex.class, ElasticGroupIndex.class)
             .build(GroupIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            .implement(ProjectIndex.class, ElasticProjectIndex.class)
+            .build(ProjectIndex.Factory.class));
 
     install(new IndexModule(threads));
     if (singleVersions == null) {
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
new file mode 100644
index 0000000..780f023
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
@@ -0,0 +1,215 @@
+// 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.elasticsearch;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.project.ProjectField;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import io.searchbox.client.JestResult;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Bulk.Builder;
+import io.searchbox.core.Search;
+import io.searchbox.core.search.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ElasticProjectIndex extends AbstractElasticIndex<Project.NameKey, ProjectData>
+    implements ProjectIndex {
+  static class ProjectMapping {
+    MappingProperties projects;
+
+    ProjectMapping(Schema<ProjectData> schema) {
+      this.projects = ElasticMapping.createMapping(schema);
+    }
+  }
+
+  static final String PROJECTS = "projects";
+  static final String PROJECTS_PREFIX = PROJECTS + "_";
+
+  private static final Logger log = LoggerFactory.getLogger(ElasticProjectIndex.class);
+
+  private final ProjectMapping mapping;
+  private final Provider<ProjectCache> projectCache;
+
+  @Inject
+  ElasticProjectIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<ProjectCache> projectCache,
+      JestClientBuilder clientBuilder,
+      @Assisted Schema<ProjectData> schema) {
+    super(cfg, sitePaths, schema, clientBuilder, PROJECTS_PREFIX);
+    this.projectCache = projectCache;
+    this.mapping = new ProjectMapping(schema);
+  }
+
+  @Override
+  public void replace(ProjectData projectState) throws IOException {
+    Bulk bulk =
+        new Bulk.Builder()
+            .defaultIndex(indexName)
+            .defaultType(PROJECTS)
+            .addAction(insert(PROJECTS, projectState))
+            .refresh(true)
+            .build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(
+          String.format(
+              "Failed to replace project %s in index %s: %s",
+              projectState.getProject().getName(), indexName, result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
+      throws QueryParseException {
+    return new QuerySource(p, opts);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, Project.NameKey nameKey) {
+    return builder.addAction(delete(PROJECTS, nameKey));
+  }
+
+  @Override
+  protected String getMappings() {
+    ImmutableMap<String, ProjectMapping> mappings = ImmutableMap.of("mappings", mapping);
+    return gson.toJson(mappings);
+  }
+
+  @Override
+  protected String getId(ProjectData projectState) {
+    return projectState.getProject().getName();
+  }
+
+  private class QuerySource implements DataSource<ProjectData> {
+    private final Search search;
+    private final Set<String> fields;
+
+    QuerySource(Predicate<ProjectData> p, QueryOptions opts) throws QueryParseException {
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.projectFields(opts);
+      SearchSourceBuilder searchSource =
+          new SearchSourceBuilder()
+              .query(qb)
+              .from(opts.start())
+              .size(opts.limit())
+              .fields(Lists.newArrayList(fields));
+
+      Sort sort = new Sort(ProjectField.NAME.getName(), Sorting.ASC);
+      sort.setIgnoreUnmapped();
+
+      search =
+          new Search.Builder(searchSource.toString())
+              .addType(PROJECTS)
+              .addIndex(indexName)
+              .addSort(ImmutableList.of(sort))
+              .build();
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<ProjectData> read() throws OrmException {
+      try {
+        List<ProjectData> results = Collections.emptyList();
+        JestResult result = client.execute(search);
+        if (result.isSucceeded()) {
+          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toProjectData(json.get(i)));
+            }
+          }
+        } else {
+          log.error(result.getErrorMessage());
+        }
+        final List<ProjectData> r = Collections.unmodifiableList(results);
+        return new ResultSet<ProjectData>() {
+          @Override
+          public Iterator<ProjectData> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<ProjectData> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return search.toString();
+    }
+
+    private ProjectData toProjectData(JsonElement json) {
+      JsonElement source = json.getAsJsonObject().get("_source");
+      if (source == null) {
+        source = json.getAsJsonObject().get("fields");
+      }
+
+      Project.NameKey nameKey =
+          new Project.NameKey(
+              source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
+      return projectCache.get().get(nameKey).toProjectData();
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.java
new file mode 100644
index 0000000..4af53e3
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryProjectsTest.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.elasticsearch;
+
+import com.google.gerrit.elasticsearch.ElasticTestUtils.ElasticNodeInfo;
+import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+public class ElasticQueryProjectsTest extends AbstractQueryProjectsTest {
+  private static ElasticNodeInfo nodeInfo;
+
+  @BeforeClass
+  public static void startIndexService() throws InterruptedException, ExecutionException {
+    if (nodeInfo != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+    nodeInfo = ElasticTestUtils.startElasticsearchNode();
+    ElasticTestUtils.createAllIndexes(nodeInfo);
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (nodeInfo != null) {
+      nodeInfo.node.close();
+      nodeInfo.elasticDir.delete();
+      nodeInfo = null;
+    }
+  }
+
+  @After
+  public void cleanupIndex() {
+    if (nodeInfo != null) {
+      ElasticTestUtils.deleteAllIndexes(nodeInfo);
+      ElasticTestUtils.createAllIndexes(nodeInfo);
+    }
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    ElasticTestUtils.configure(elasticsearchConfig, nodeInfo.port);
+    return Guice.createInjector(new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index fac10eb..c37a8ec 100644
--- a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -20,12 +20,14 @@
 import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CLOSED_CHANGES;
 import static com.google.gerrit.elasticsearch.ElasticChangeIndex.OPEN_CHANGES;
 import static com.google.gerrit.elasticsearch.ElasticGroupIndex.GROUPS_PREFIX;
+import static com.google.gerrit.elasticsearch.ElasticProjectIndex.PROJECTS_PREFIX;
 
 import com.google.common.base.Strings;
 import com.google.common.io.Files;
 import com.google.gerrit.elasticsearch.ElasticAccountIndex.AccountMapping;
 import com.google.gerrit.elasticsearch.ElasticChangeIndex.ChangeMapping;
 import com.google.gerrit.elasticsearch.ElasticGroupIndex.GroupMapping;
+import com.google.gerrit.elasticsearch.ElasticProjectIndex.ProjectMapping;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.group.InternalGroup;
@@ -33,6 +35,8 @@
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.project.ProjectData;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gson.FieldNamingPolicy;
 import com.google.gson.Gson;
@@ -157,6 +161,18 @@
         .addMapping(ElasticGroupIndex.GROUPS, gson.toJson(groupMapping))
         .execute()
         .actionGet();
+
+    Schema<ProjectData> projectSchema = ProjectSchemaDefinitions.INSTANCE.getLatest();
+    ProjectMapping projectMapping = new ProjectMapping(projectSchema);
+    nodeInfo
+        .node
+        .client()
+        .admin()
+        .indices()
+        .prepareCreate(String.format("%s%04d", PROJECTS_PREFIX, projectSchema.getVersion()))
+        .addMapping(ElasticProjectIndex.PROJECTS, gson.toJson(projectMapping))
+        .execute()
+        .actionGet();
   }
 
   private static String getHttpPort(Node node) throws InterruptedException, ExecutionException {
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
index 322b076..672602d 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/DescriptionInput.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.extensions.api.projects;
 
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class DescriptionInput {
-  @DefaultInput public String description;
+public class DescriptionInput extends com.google.gerrit.extensions.common.DescriptionInput {
   public String commitMessage;
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
index e4a659c..02cce3a 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -58,6 +58,24 @@
 
   ListRequest list();
 
+  /**
+   * Query projects.
+   *
+   * <p>Example code: {@code query().withQuery("name:project").get()}
+   *
+   * @return API for setting parameters and getting result.
+   */
+  QueryRequest query();
+
+  /**
+   * Query projects.
+   *
+   * <p>Shortcut API for {@code query().withQuery(String)}.
+   *
+   * @see #query()
+   */
+  QueryRequest query(String query);
+
   abstract class ListRequest {
     public enum FilterType {
       CODE,
@@ -172,6 +190,56 @@
   }
 
   /**
+   * API for setting parameters and getting result. Used for {@code query()}.
+   *
+   * @see #query()
+   */
+  abstract class QueryRequest {
+    private String query;
+    private int limit;
+    private int start;
+
+    /** Execute query and returns the matched projects as list. */
+    public abstract List<ProjectInfo> get() throws RestApiException;
+
+    /**
+     * Set query.
+     *
+     * @param query needs to be in human-readable form.
+     */
+    public QueryRequest withQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
+    /**
+     * Set limit for returned list of projects. Optional; server-default is used when not provided.
+     */
+    public QueryRequest withLimit(int limit) {
+      this.limit = limit;
+      return this;
+    }
+
+    /** Set number of projects to skip. Optional; no projects are skipped when not provided. */
+    public QueryRequest withStart(int start) {
+      this.start = start;
+      return this;
+    }
+
+    public String getQuery() {
+      return query;
+    }
+
+    public int getLimit() {
+      return limit;
+    }
+
+    public int getStart() {
+      return start;
+    }
+  }
+
+  /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
    */
@@ -195,5 +263,15 @@
     public ListRequest list() {
       throw new NotImplementedException();
     }
+
+    @Override
+    public QueryRequest query() {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public QueryRequest query(String query) {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DescriptionInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DescriptionInput.java
new file mode 100644
index 0000000..c0733dc
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/DescriptionInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class DescriptionInput {
+  @DefaultInput public String description;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeysInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeysInput.java
new file mode 100644
index 0000000..95eb1c4
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/GpgKeysInput.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class GpgKeysInput {
+  public List<String> add;
+  public List<String> delete;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/HttpPasswordInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/HttpPasswordInput.java
new file mode 100644
index 0000000..246c7cf
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/HttpPasswordInput.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+public class HttpPasswordInput {
+  public String httpPassword;
+  public boolean generate;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Input.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Input.java
new file mode 100644
index 0000000..68f864c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/Input.java
@@ -0,0 +1,20 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+/** A generic empty input. */
+public class Input {
+  public Input() {}
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/NameInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/NameInput.java
new file mode 100644
index 0000000..463eee1
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/NameInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class NameInput {
+  @DefaultInput public String name;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/OwnerInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/OwnerInput.java
new file mode 100644
index 0000000..1272a5c
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/OwnerInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class OwnerInput {
+  @DefaultInput public String owner;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInput.java
new file mode 100644
index 0000000..e04ea23
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/SshKeyInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.RawInput;
+
+public class SshKeyInput {
+  public RawInput raw;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TopicInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TopicInput.java
new file mode 100644
index 0000000..889efd4
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/TopicInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class TopicInput {
+  @DefaultInput public String topic;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UsernameInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UsernameInput.java
new file mode 100644
index 0000000..baff84b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/UsernameInput.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class UsernameInput {
+  @DefaultInput public String username;
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectIndexedListener.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectIndexedListener.java
new file mode 100644
index 0000000..93a610b
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/events/ProjectIndexedListener.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/** Notified whenever a project is indexed */
+@ExtensionPoint
+public interface ProjectIndexedListener {
+  /**
+   * Invoked when a project is indexed
+   *
+   * @param project name of the project
+   */
+  void onProjectIndexed(String project);
+}
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
index ba79a6f..a34243f 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgApiAdapterImpl.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.GpgKeysInput;
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -29,6 +30,7 @@
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
@@ -38,15 +40,15 @@
 import org.eclipse.jgit.transport.PushCertificateParser;
 
 public class GpgApiAdapterImpl implements GpgApiAdapter {
-  private final PostGpgKeys postGpgKeys;
-  private final GpgKeys gpgKeys;
+  private final Provider<PostGpgKeys> postGpgKeys;
+  private final Provider<GpgKeys> gpgKeys;
   private final GpgKeyApiImpl.Factory gpgKeyApiFactory;
   private final GerritPushCertificateChecker.Factory pushCertCheckerFactory;
 
   @Inject
   GpgApiAdapterImpl(
-      PostGpgKeys postGpgKeys,
-      GpgKeys gpgKeys,
+      Provider<PostGpgKeys> postGpgKeys,
+      Provider<GpgKeys> gpgKeys,
       GpgKeyApiImpl.Factory gpgKeyApiFactory,
       GerritPushCertificateChecker.Factory pushCertCheckerFactory) {
     this.postGpgKeys = postGpgKeys;
@@ -64,7 +66,7 @@
   public Map<String, GpgKeyInfo> listGpgKeys(AccountResource account)
       throws RestApiException, GpgException {
     try {
-      return gpgKeys.list().apply(account);
+      return gpgKeys.get().list().apply(account);
     } catch (OrmException | PGPException | IOException e) {
       throw new GpgException(e);
     }
@@ -74,11 +76,11 @@
   public Map<String, GpgKeyInfo> putGpgKeys(
       AccountResource account, List<String> add, List<String> delete)
       throws RestApiException, GpgException {
-    PostGpgKeys.Input in = new PostGpgKeys.Input();
+    GpgKeysInput in = new GpgKeysInput();
     in.add = add;
     in.delete = delete;
     try {
-      return postGpgKeys.apply(account, in);
+      return postGpgKeys.get().apply(account, in);
     } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new GpgException(e);
     }
@@ -88,7 +90,7 @@
   public GpgKeyApi gpgKey(AccountResource account, IdString idStr)
       throws RestApiException, GpgException {
     try {
-      return gpgKeyApiFactory.create(gpgKeys.parse(account, idStr));
+      return gpgKeyApiFactory.create(gpgKeys.get().parse(account, idStr));
     } catch (PGPException | OrmException | IOException e) {
       throw new GpgException(e);
     }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
index 14a4c6d..25b472d 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/api/GpgKeyApiImpl.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.gpg.server.DeleteGpgKey;
 import com.google.gerrit.gpg.server.GpgKey;
@@ -55,7 +56,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      delete.apply(rsrc, new DeleteGpgKey.Input());
+      delete.apply(rsrc, new Input());
     } catch (PGPException | OrmException | IOException | ConfigInvalidException e) {
       throw new RestApiException("Cannot delete GPG key", e);
     }
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index baf5a58..b9d89ee 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -18,11 +18,11 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 
 import com.google.common.io.BaseEncoding;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.DeleteGpgKey.Input;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
@@ -38,7 +38,6 @@
 import org.eclipse.jgit.lib.RefUpdate;
 
 public class DeleteGpgKey implements RestModifyView<GpgKey, Input> {
-  public static class Input {}
 
   private final Provider<PersonIdent> serverIdent;
   private final Provider<PublicKeyStore> storeProvider;
diff --git a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 7d1aceed..b2383ca 100644
--- a/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/gerrit-gpg/src/main/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -29,6 +29,7 @@
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
+import com.google.gerrit.extensions.common.GpgKeysInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -38,7 +39,6 @@
 import com.google.gerrit.gpg.GerritPublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyChecker;
 import com.google.gerrit.gpg.PublicKeyStore;
-import com.google.gerrit.gpg.server.PostGpgKeys.Input;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
@@ -75,12 +75,7 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class PostGpgKeys implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    public List<String> add;
-    public List<String> delete;
-  }
-
+public class PostGpgKeys implements RestModifyView<AccountResource, GpgKeysInput> {
   private final Logger log = LoggerFactory.getLogger(getClass());
   private final Provider<PersonIdent> serverIdent;
   private final Provider<CurrentUser> self;
@@ -112,7 +107,7 @@
   }
 
   @Override
-  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, Input input)
+  public Map<String, GpgKeyInfo> apply(AccountResource rsrc, GpgKeysInput input)
       throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
           PGPException, OrmException, IOException, ConfigInvalidException {
     GpgKeys.checkVisible(self, rsrc);
@@ -148,7 +143,8 @@
     }
   }
 
-  private Set<Fingerprint> readKeysToRemove(Input input, Collection<ExternalId> existingExtIds) {
+  private Set<Fingerprint> readKeysToRemove(
+      GpgKeysInput input, Collection<ExternalId> existingExtIds) {
     if (input.delete == null || input.delete.isEmpty()) {
       return ImmutableSet.of();
     }
@@ -163,7 +159,7 @@
     return fingerprints;
   }
 
-  private List<PGPPublicKeyRing> readKeysToAdd(Input input, Set<Fingerprint> toRemove)
+  private List<PGPPublicKeyRing> readKeysToAdd(GpgKeysInput input, Set<Fingerprint> toRemove)
       throws BadRequestException, IOException {
     if (input.add == null || input.add.isEmpty()) {
       return ImmutableList.of();
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
index 7c62ed7..a0c4aa6 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/RelativeDateFormatter.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.client;
 
-import static com.google.gerrit.client.CommonConstants.C;
-import static com.google.gerrit.client.CommonMessages.M;
-
 import java.util.Date;
 
 /**
@@ -24,6 +21,9 @@
  * defined by {@code git log --relative-date}.
  */
 public class RelativeDateFormatter {
+  private static CommonConstants constants;
+  private static CommonMessages messages;
+
   static final long SECOND_IN_MILLIS = 1000;
   static final long MINUTE_IN_MILLIS = 60 * SECOND_IN_MILLIS;
   static final long HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
@@ -32,6 +32,19 @@
   static final long MONTH_IN_MILLIS = 30 * DAY_IN_MILLIS;
   static final long YEAR_IN_MILLIS = 365 * DAY_IN_MILLIS;
 
+  static void setConstants(CommonConstants c, CommonMessages m) {
+    constants = c;
+    messages = m;
+  }
+
+  private static CommonConstants c() {
+    return constants != null ? constants : CommonConstants.C;
+  }
+
+  private static CommonMessages m() {
+    return messages != null ? messages : CommonMessages.M;
+  }
+
   /**
    * @param when {@link Date} to format
    * @return age of given {@link Date} compared to now formatted in the same relative format as
@@ -42,81 +55,85 @@
 
     // shouldn't happen in a perfect world
     if (ageMillis < 0) {
-      return C.inTheFuture();
+      return c().inTheFuture();
     }
 
     // seconds
     if (ageMillis < upperLimit(MINUTE_IN_MILLIS)) {
       long seconds = round(ageMillis, SECOND_IN_MILLIS);
       if (seconds == 1) {
-        return C.oneSecondAgo();
+        return c().oneSecondAgo();
       }
-      return M.secondsAgo(seconds);
+      return m().secondsAgo(seconds);
     }
 
     // minutes
     if (ageMillis < upperLimit(HOUR_IN_MILLIS)) {
       long minutes = round(ageMillis, MINUTE_IN_MILLIS);
       if (minutes == 1) {
-        return C.oneMinuteAgo();
+        return c().oneMinuteAgo();
       }
-      return M.minutesAgo(minutes);
+      return m().minutesAgo(minutes);
     }
 
     // hours
     if (ageMillis < upperLimit(DAY_IN_MILLIS)) {
       long hours = round(ageMillis, HOUR_IN_MILLIS);
       if (hours == 1) {
-        return C.oneHourAgo();
+        return c().oneHourAgo();
       }
-      return M.hoursAgo(hours);
+      return m().hoursAgo(hours);
     }
 
     // up to 14 days use days
     if (ageMillis < 14 * DAY_IN_MILLIS) {
       long days = round(ageMillis, DAY_IN_MILLIS);
       if (days == 1) {
-        return C.oneDayAgo();
+        return c().oneDayAgo();
       }
-      return M.daysAgo(days);
+      return m().daysAgo(days);
     }
 
     // up to 10 weeks use weeks
     if (ageMillis < 10 * WEEK_IN_MILLIS) {
       long weeks = round(ageMillis, WEEK_IN_MILLIS);
       if (weeks == 1) {
-        return C.oneWeekAgo();
+        return c().oneWeekAgo();
       }
-      return M.weeksAgo(weeks);
+      return m().weeksAgo(weeks);
     }
 
     // months
     if (ageMillis < YEAR_IN_MILLIS) {
       long months = round(ageMillis, MONTH_IN_MILLIS);
       if (months == 1) {
-        return C.oneMonthAgo();
+        return c().oneMonthAgo();
       }
-      return M.monthsAgo(months);
+      return m().monthsAgo(months);
     }
 
     // up to 5 years use "year, months" rounded to months
     if (ageMillis < 5 * YEAR_IN_MILLIS) {
       long years = ageMillis / YEAR_IN_MILLIS;
-      String yearLabel = (years > 1) ? C.years() : C.year();
+      String yearLabel = (years > 1) ? c().years() : c().year();
       long months = round(ageMillis % YEAR_IN_MILLIS, MONTH_IN_MILLIS);
-      String monthLabel = (months > 1) ? C.months() : (months == 1 ? C.month() : "");
+      String monthLabel = (months > 1) ? c().months() : (months == 1 ? c().month() : "");
       if (months == 0) {
-        return M.years0MonthsAgo(years, yearLabel);
+        return m().years0MonthsAgo(years, yearLabel);
       }
-      return M.yearsMonthsAgo(years, yearLabel, months, monthLabel);
+      if (months == 12) {
+        years++;
+        return m().years0MonthsAgo(years, yearLabel);
+      }
+      return m().yearsMonthsAgo(years, yearLabel, months, monthLabel);
     }
 
     // years
     long years = round(ageMillis, YEAR_IN_MILLIS);
     if (years == 1) {
-      return C.oneYearAgo();
+      return c().oneYearAgo();
     }
-    return M.yearsAgo(years);
+    return m().yearsAgo(years);
   }
 
   private static long upperLimit(long unit) {
diff --git a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
index 937fc96..5180410 100644
--- a/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
+++ b/gerrit-gwtui-common/src/test/java/com/google/gerrit/client/RelativeDateFormatterTest.java
@@ -22,11 +22,23 @@
 import static org.junit.Assert.assertEquals;
 
 import java.util.Date;
-import org.eclipse.jgit.util.RelativeDateFormatter;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 public class RelativeDateFormatterTest {
 
+  @BeforeClass
+  public static void setConstants() {
+    Constants c = new Constants();
+    RelativeDateFormatter.setConstants(c, c);
+  }
+
+  @AfterClass
+  public static void unsetConstants() {
+    RelativeDateFormatter.setConstants(null, null);
+  }
+
   private static void assertFormat(long ageFromNow, long timeUnit, String expectedFormat) {
     Date d = new Date(System.currentTimeMillis() - ageFromNow * timeUnit);
     String s = RelativeDateFormatter.format(d);
@@ -41,7 +53,7 @@
 
   @Test
   public void formatSeconds() {
-    assertFormat(1, SECOND_IN_MILLIS, "1 seconds ago");
+    assertFormat(1, SECOND_IN_MILLIS, "1 second ago");
     assertFormat(89, SECOND_IN_MILLIS, "89 seconds ago");
   }
 
@@ -85,7 +97,7 @@
     assertFormat(380, DAY_IN_MILLIS, "1 year, 1 month ago");
     assertFormat(410, DAY_IN_MILLIS, "1 year, 2 months ago");
     assertFormat(2, YEAR_IN_MILLIS, "2 years ago");
-    assertFormat(1824, DAY_IN_MILLIS, "4 years, 12 months ago");
+    assertFormat(1824, DAY_IN_MILLIS, "5 years ago");
   }
 
   @Test
@@ -93,4 +105,111 @@
     assertFormat(5, YEAR_IN_MILLIS, "5 years ago");
     assertFormat(60, YEAR_IN_MILLIS, "60 years ago");
   }
+
+  private static class Constants implements CommonConstants, CommonMessages {
+    @Override
+    public String inTheFuture() {
+      return "in the future";
+    }
+
+    @Override
+    public String month() {
+      return "month";
+    }
+
+    @Override
+    public String months() {
+      return "months";
+    }
+
+    @Override
+    public String year() {
+      return "year";
+    }
+
+    @Override
+    public String years() {
+      return "years";
+    }
+
+    @Override
+    public String oneSecondAgo() {
+      return "1 second ago";
+    }
+
+    @Override
+    public String oneMinuteAgo() {
+      return "1 minute ago";
+    }
+
+    @Override
+    public String oneHourAgo() {
+      return "1 hour ago";
+    }
+
+    @Override
+    public String oneDayAgo() {
+      return "1 day ago";
+    }
+
+    @Override
+    public String oneWeekAgo() {
+      return "1 week ago";
+    }
+
+    @Override
+    public String oneMonthAgo() {
+      return "1 month ago";
+    }
+
+    @Override
+    public String oneYearAgo() {
+      return "1 year ago";
+    }
+
+    @Override
+    public String secondsAgo(long seconds) {
+      return seconds + " seconds ago";
+    }
+
+    @Override
+    public String minutesAgo(long minutes) {
+      return minutes + " minutes ago";
+    }
+
+    @Override
+    public String hoursAgo(long hours) {
+      return hours + " hours ago";
+    }
+
+    @Override
+    public String daysAgo(long days) {
+      return days + " days ago";
+    }
+
+    @Override
+    public String weeksAgo(long weeks) {
+      return weeks + " weeks ago";
+    }
+
+    @Override
+    public String monthsAgo(long months) {
+      return months + " months ago";
+    }
+
+    @Override
+    public String yearsAgo(long years) {
+      return years + " years ago";
+    }
+
+    @Override
+    public String years0MonthsAgo(long years, String yearLabel) {
+      return years + " " + yearLabel + " ago";
+    }
+
+    @Override
+    public String yearsMonthsAgo(long years, String yearLabel, long months, String monthLabel) {
+      return years + " " + yearLabel + ", " + months + " " + monthLabel + " ago";
+    }
+  }
 }
diff --git a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index c3b522a..b22a2a4 100644
--- a/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/gerrit-httpd/src/main/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -21,7 +21,7 @@
  * @param staticResourcePath
  * @param? versionInfo
  */
-{template .Index kind="html"}
+{template .Index}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
diff --git a/gerrit-index/BUILD b/gerrit-index/BUILD
index 41eed60..11c4f08 100644
--- a/gerrit-index/BUILD
+++ b/gerrit-index/BUILD
@@ -1,7 +1,10 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
-QUERY_PARSE_EXCEPTION_SRCS = ["src/main/java/com/google/gerrit/index/query/QueryParseException.java"]
+QUERY_PARSE_EXCEPTION_SRCS = [
+    "src/main/java/com/google/gerrit/index/query/QueryParseException.java",
+    "src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java",
+]
 
 java_library(
     name = "query_exception",
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
index b53b59b..b5b36f1 100644
--- a/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexConfig.java
@@ -61,15 +61,15 @@
   public abstract static class Builder {
     public abstract Builder maxLimit(int maxLimit);
 
-    abstract int maxLimit();
+    public abstract int maxLimit();
 
     public abstract Builder maxPages(int maxPages);
 
-    abstract int maxPages();
+    public abstract int maxPages();
 
     public abstract Builder maxTerms(int maxTerms);
 
-    abstract int maxTerms();
+    public abstract int maxTerms();
 
     public abstract Builder separateChangeSubIndexes(boolean separate);
 
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java
new file mode 100644
index 0000000..67c159e
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java
@@ -0,0 +1,32 @@
+// 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.index.query;
+
+/**
+ * Exception thrown when a search query is invalid.
+ *
+ * <p><b>NOTE:</b> the message is visible to end users.
+ */
+public class QueryRequiresAuthException extends QueryParseException {
+  private static final long serialVersionUID = 1L;
+
+  public QueryRequiresAuthException(String message) {
+    super(message);
+  }
+
+  public QueryRequiresAuthException(String msg, Throwable why) {
+    super(msg, why);
+  }
+}
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index d738540..d5d6360 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
 import com.google.inject.AbstractModule;
 import com.google.inject.Provides;
 import com.google.inject.Singleton;
@@ -85,7 +86,10 @@
         new FactoryModuleBuilder()
             .implement(GroupIndex.class, LuceneGroupIndex.class)
             .build(GroupIndex.Factory.class));
-
+    install(
+        new FactoryModuleBuilder()
+            .implement(ProjectIndex.class, LuceneProjectIndex.class)
+            .build(ProjectIndex.Factory.class));
     install(new IndexModule(threads));
     if (singleVersions == null) {
       install(new MultiVersionModule());
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneProjectIndex.java
new file mode 100644
index 0000000..6354f61
--- /dev/null
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -0,0 +1,200 @@
+// 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.lucene;
+
+import static com.google.gerrit.server.index.project.ProjectField.NAME;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreDoc;
+import org.apache.lucene.search.SearcherFactory;
+import org.apache.lucene.search.Sort;
+import org.apache.lucene.search.SortField;
+import org.apache.lucene.search.TopFieldDocs;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.store.FSDirectory;
+import org.apache.lucene.store.RAMDirectory;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, ProjectData>
+    implements ProjectIndex {
+  private static final Logger log = LoggerFactory.getLogger(LuceneProjectIndex.class);
+
+  private static final String PROJECTS = "projects";
+
+  private static final String NAME_SORT_FIELD = sortFieldName(NAME);
+
+  private static Term idTerm(ProjectData projectState) {
+    return idTerm(projectState.getProject().getNameKey());
+  }
+
+  private static Term idTerm(Project.NameKey nameKey) {
+    return QueryBuilder.stringTerm(NAME.getName(), nameKey.get());
+  }
+
+  private final GerritIndexWriterConfig indexWriterConfig;
+  private final QueryBuilder<ProjectData> queryBuilder;
+  private final Provider<ProjectCache> projectCache;
+
+  private static Directory dir(Schema<ProjectData> schema, Config cfg, SitePaths sitePaths)
+      throws IOException {
+    if (LuceneIndexModule.isInMemoryTest(cfg)) {
+      return new RAMDirectory();
+    }
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, PROJECTS, schema);
+    return FSDirectory.open(indexDir);
+  }
+
+  @Inject
+  LuceneProjectIndex(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      Provider<ProjectCache> projectCache,
+      @Assisted Schema<ProjectData> schema)
+      throws IOException {
+    super(
+        schema,
+        sitePaths,
+        dir(schema, cfg, sitePaths),
+        PROJECTS,
+        null,
+        new GerritIndexWriterConfig(cfg, PROJECTS),
+        new SearcherFactory());
+    this.projectCache = projectCache;
+
+    indexWriterConfig = new GerritIndexWriterConfig(cfg, PROJECTS);
+    queryBuilder = new QueryBuilder<>(schema, indexWriterConfig.getAnalyzer());
+  }
+
+  @Override
+  public void replace(ProjectData projectState) throws IOException {
+    try {
+      replace(idTerm(projectState), toDocument(projectState)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public void delete(Project.NameKey nameKey) throws IOException {
+    try {
+      delete(idTerm(nameKey)).get();
+    } catch (ExecutionException | InterruptedException e) {
+      throw new IOException(e);
+    }
+  }
+
+  @Override
+  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
+      throws QueryParseException {
+    return new QuerySource(
+        opts,
+        queryBuilder.toQuery(p),
+        new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
+  }
+
+  private class QuerySource implements DataSource<ProjectData> {
+    private final QueryOptions opts;
+    private final Query query;
+    private final Sort sort;
+
+    private QuerySource(QueryOptions opts, Query query, Sort sort) {
+      this.opts = opts;
+      this.query = query;
+      this.sort = sort;
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<ProjectData> read() throws OrmException {
+      IndexSearcher searcher = null;
+      try {
+        searcher = acquire();
+        int realLimit = opts.start() + opts.limit();
+        TopFieldDocs docs = searcher.search(query, realLimit, sort);
+        List<ProjectData> result = new ArrayList<>(docs.scoreDocs.length);
+        for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
+          ScoreDoc sd = docs.scoreDocs[i];
+          Document doc = searcher.doc(sd.doc, IndexUtils.projectFields(opts));
+          result.add(toProjectData(doc));
+        }
+        final List<ProjectData> r = Collections.unmodifiableList(result);
+        return new ResultSet<ProjectData>() {
+          @Override
+          public Iterator<ProjectData> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<ProjectData> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      } finally {
+        if (searcher != null) {
+          try {
+            release(searcher);
+          } catch (IOException e) {
+            log.warn("cannot release Lucene searcher", e);
+          }
+        }
+      }
+    }
+  }
+
+  private ProjectData toProjectData(Document doc) {
+    Project.NameKey nameKey = new Project.NameKey(doc.getField(NAME.getName()).stringValue());
+    return projectCache.get().get(nameKey).toProjectData();
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index bee9928..d8451d5 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.pgm.util.ThreadLimiter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
@@ -170,6 +171,7 @@
           @Override
           protected void configure() {
             factory(ChangeResource.Factory.class);
+            bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
           }
         });
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index feb91e7..ab491f7c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -16,11 +16,13 @@
 
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIdReader;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.File;
@@ -67,7 +69,17 @@
 
         PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
         ExternalIdsUpdate.commit(
-            repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+            new Project.NameKey(allUsers),
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            commitMessage,
+            serverIdent,
+            serverIdent,
+            null,
+            GitReferenceUpdated.DISABLED);
       }
     }
   }
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 86468c9..9a81c52 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
@@ -17,6 +17,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
@@ -31,8 +32,11 @@
 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.group.InternalGroup;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -55,7 +59,8 @@
   private final SequencesOnInit sequencesOnInit;
   private final GroupsOnInit groupsOnInit;
   private SchemaFactory<ReviewDb> dbFactory;
-  private AccountIndexCollection indexCollection;
+  private AccountIndexCollection accountIndexCollection;
+  private GroupIndexCollection groupIndexCollection;
 
   @Inject
   InitAdminUser(
@@ -86,8 +91,13 @@
   }
 
   @Inject(optional = true)
-  void set(AccountIndexCollection indexCollection) {
-    this.indexCollection = indexCollection;
+  void set(AccountIndexCollection accountIndexCollection) {
+    this.accountIndexCollection = accountIndexCollection;
+  }
+
+  @Inject(optional = true)
+  void set(GroupIndexCollection groupIndexCollection) {
+    this.groupIndexCollection = groupIndexCollection;
   }
 
   @Override
@@ -138,9 +148,15 @@
                   Collections.singleton(adminGroup.getGroupUUID()),
                   extIds,
                   new HashMap<>());
-          for (AccountIndex accountIndex : indexCollection.getWriteIndexes()) {
+          for (AccountIndex accountIndex : accountIndexCollection.getWriteIndexes()) {
             accountIndex.replace(as);
           }
+
+          InternalGroup adminInternalGroup =
+              InternalGroup.create(adminGroup, ImmutableSet.of(id), ImmutableSet.of());
+          for (GroupIndex groupIndex : groupIndexCollection.getWriteIndexes()) {
+            groupIndex.replace(adminInternalGroup);
+          }
         }
       }
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
index bfad889..e1cef62 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gwtorm.server.OrmException;
@@ -40,6 +41,7 @@
     RepoSequence accountSeq =
         new RepoSequence(
             repoManager,
+            GitReferenceUpdated.DISABLED,
             new Project.NameKey(allUsersName.get()),
             Sequences.NAME_ACCOUNTS,
             accountSeed,
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
index d331347..20edbd3 100755
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -289,7 +289,11 @@
 
 GERRIT_FDS=`get_config --int core.packedGitOpenFiles`
 test -z "$GERRIT_FDS" && GERRIT_FDS=128
-GERRIT_FDS=`expr $GERRIT_FDS + $GERRIT_FDS`
+FDS_MULTIPLIER=2
+USE_LFS=`get_config --get lfs.plugin`
+test -n "$USE_LFS" && FDS_MULTIPLIER=3
+
+GERRIT_FDS=`expr $FDS_MULTIPLIER \* $GERRIT_FDS`
 test $GERRIT_FDS -lt 1024 && GERRIT_FDS=1024
 
 GERRIT_STARTUP_TIMEOUT=`get_config --get container.startupTimeout`
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
index 28fde9e..930f3f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.RepoSequence;
@@ -64,6 +65,7 @@
       Provider<ReviewDb> db,
       NotesMigration migration,
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       AllProjectsName allProjects,
       AllUsersName allUsers,
       MetricMaker metrics) {
@@ -74,6 +76,7 @@
     accountSeq =
         new RepoSequence(
             repoManager,
+            gitRefUpdated,
             allUsers,
             NAME_ACCOUNTS,
             () -> ReviewDb.FIRST_ACCOUNT_ID,
@@ -84,7 +87,8 @@
     RepoSequence.Seed changeSeed = () -> db.get().nextChangeId() + gap;
     int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
     changeSeq =
-        new RepoSequence(repoManager, allProjects, NAME_CHANGES, changeSeed, changeBatchSize);
+        new RepoSequence(
+            repoManager, gitRefUpdated, allProjects, NAME_CHANGES, changeSeed, changeBatchSize);
 
     nextIdLatency =
         metrics.newTimer(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index a8cc0f4..12bd8ff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -37,6 +37,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -163,6 +164,7 @@
       ImmutableSortedSet.of(DEFAULT_LABEL);
 
   private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<ReviewDb> dbProvider;
   private final PersonIdent serverIdent;
@@ -172,12 +174,14 @@
   @Inject
   StarredChangesUtil(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
       Provider<ReviewDb> dbProvider,
       @GerritPersonIdent PersonIdent serverIdent,
       ChangeIndexer indexer,
       Provider<InternalChangeQuery> queryProvider) {
     this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.dbProvider = dbProvider;
     this.serverIdent = serverIdent;
@@ -403,18 +407,8 @@
       throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
     }
 
-    Set<Integer> reviewedPatchSets =
-        labels
-            .stream()
-            .filter(l -> l.startsWith(REVIEWED_LABEL))
-            .map(l -> Integer.valueOf(l.substring(REVIEWED_LABEL.length() + 1)))
-            .collect(toSet());
-    Set<Integer> unreviewedPatchSets =
-        labels
-            .stream()
-            .filter(l -> l.startsWith(UNREVIEWED_LABEL))
-            .map(l -> Integer.valueOf(l.substring(UNREVIEWED_LABEL.length() + 1)))
-            .collect(toSet());
+    Set<Integer> reviewedPatchSets = getStarredPatchSets(labels, REVIEWED_LABEL);
+    Set<Integer> unreviewedPatchSets = getStarredPatchSets(labels, UNREVIEWED_LABEL);
     Optional<Integer> ps =
         Sets.intersection(reviewedPatchSets, unreviewedPatchSets).stream().findFirst();
     if (ps.isPresent()) {
@@ -423,6 +417,15 @@
     }
   }
 
+  public static Set<Integer> getStarredPatchSets(Set<String> labels, String label) {
+    return labels
+        .stream()
+        .filter(l -> l.startsWith(label))
+        .filter(l -> Ints.tryParse(l.substring(label.length() + 1)) != null)
+        .map(l -> Integer.valueOf(l.substring(label.length() + 1)))
+        .collect(toSet());
+  }
+
   private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
     if (labels == null) {
       return;
@@ -455,6 +458,7 @@
         case FORCED:
         case NO_CHANGE:
         case FAST_FORWARD:
+          gitRefUpdated.fire(allUsers, u, null);
           return;
         case IO_FAILURE:
         case LOCK_FAILURE:
@@ -481,6 +485,7 @@
     RefUpdate.Result result = u.delete();
     switch (result) {
       case FORCED:
+        gitRefUpdated.fire(allUsers, u, null);
         return;
       case NEW:
       case NO_CHANGE:
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 adca6fb..d062842 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
@@ -224,7 +224,8 @@
     private ImmutableSet<AccountGroup.UUID> getGroupsWithMember(ReviewDb db, Account.Id memberId)
         throws OrmException {
       Stream<InternalGroup> internalGroupStream;
-      if (groupIndexProvider.get().getSchema().hasField(GroupField.MEMBER)) {
+      if (groupIndexProvider.get() != null
+          && groupIndexProvider.get().getSchema().hasField(GroupField.MEMBER)) {
         internalGroupStream = groupQueryProvider.get().byMember(memberId).stream();
       } else {
         internalGroupStream =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
index 1c5495f..79a16e7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.common.errors.InvalidSshKeyException;
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.common.SshKeyInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RawInput;
@@ -28,7 +29,6 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AddSshKey.Input;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -45,13 +45,9 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class AddSshKey implements RestModifyView<AccountResource, Input> {
+public class AddSshKey implements RestModifyView<AccountResource, SshKeyInput> {
   private static final Logger log = LoggerFactory.getLogger(AddSshKey.class);
 
-  public static class Input {
-    public RawInput raw;
-  }
-
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
@@ -73,7 +69,7 @@
   }
 
   @Override
-  public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
+  public Response<SshKeyInfo> apply(AccountResource rsrc, SshKeyInput input)
       throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
           PermissionBackendException {
     if (self.get() != rsrc.getUser()) {
@@ -82,10 +78,10 @@
     return apply(rsrc.getUser(), input);
   }
 
-  public Response<SshKeyInfo> apply(IdentifiedUser user, Input input)
+  public Response<SshKeyInfo> apply(IdentifiedUser user, SshKeyInput input)
       throws BadRequestException, IOException, ConfigInvalidException {
     if (input == null) {
-      input = new Input();
+      input = new SshKeyInput();
     }
     if (input.raw == null) {
       throw new BadRequestException("SSH public key missing");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
index 43669c0..4b3bf39 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
@@ -16,12 +16,12 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.DeleteActive.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -32,7 +32,6 @@
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class DeleteActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
 
   private final Provider<IdentifiedUser> self;
   private final SetInactiveFlag setInactiveFlag;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index aec3a14..cccac63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -17,6 +17,7 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -25,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.DeleteEmail.Input;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -41,7 +41,6 @@
 
 @Singleton
 public class DeleteEmail implements RestModifyView<AccountResource.Email, Input> {
-  public static class Input {}
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
index f1ecd29..8dec7d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.DeleteSshKey.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +33,6 @@
 
 @Singleton
 public class DeleteSshKey implements RestModifyView<AccountResource.SshKey, Input> {
-  public static class Input {}
 
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
index 81996ab..7887929 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.group.Groups;
 import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
 import com.google.gwtorm.server.SchemaFactory;
@@ -147,16 +148,32 @@
   }
 
   static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<InternalGroup>> {
+    private final SchemaFactory<ReviewDb> schema;
+    private final Groups groups;
+    private final boolean hasGroupIndex;
     private final Provider<InternalGroupQuery> groupQueryProvider;
 
     @Inject
-    ByIdLoader(Provider<InternalGroupQuery> groupQueryProvider) {
+    ByIdLoader(
+        SchemaFactory<ReviewDb> schema,
+        Groups groups,
+        GroupIndexCollection groupIndexCollection,
+        Provider<InternalGroupQuery> groupQueryProvider) {
+      this.schema = schema;
+      this.groups = groups;
+      this.hasGroupIndex = groupIndexCollection.getSearchIndex() != null;
       this.groupQueryProvider = groupQueryProvider;
     }
 
     @Override
     public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
-      return groupQueryProvider.get().byId(key);
+      if (hasGroupIndex) {
+        return groupQueryProvider.get().byId(key);
+      }
+
+      try (ReviewDb db = schema.open()) {
+        return groups.getGroup(db, key);
+      }
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
index ecc6b8c..8436d1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.Index.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -29,7 +29,6 @@
 
 @Singleton
 public class Index implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
 
   private final AccountCache accountCache;
   private final PermissionBackend permissionBackend;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
index 7ce2ea8..cbfa172 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
@@ -16,10 +16,10 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.account.PutActive.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -29,7 +29,6 @@
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class PutActive implements RestModifyView<AccountResource, Input> {
-  public static class Input {}
 
   private final SetInactiveFlag setInactiveFlag;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index e00f6b3..5005212 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.HttpPasswordInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -24,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.PutHttpPassword.Input;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
@@ -40,12 +40,7 @@
 import org.apache.commons.codec.binary.Base64;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
-public class PutHttpPassword implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    public String httpPassword;
-    public boolean generate;
-  }
-
+public class PutHttpPassword implements RestModifyView<AccountResource, HttpPasswordInput> {
   private static final int LEN = 31;
   private static final SecureRandom rng;
 
@@ -75,7 +70,7 @@
   }
 
   @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
+  public Response<String> apply(AccountResource rsrc, HttpPasswordInput input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
           IOException, ConfigInvalidException, PermissionBackendException {
     if (self.get() != rsrc.getUser()) {
@@ -83,7 +78,7 @@
     }
 
     if (input == null) {
-      input = new Input();
+      input = new HttpPasswordInput();
     }
     input.httpPassword = Strings.emptyToNull(input.httpPassword);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index 7537230..0ac9d1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -16,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -25,7 +25,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.PutName.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -37,11 +36,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PutName implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput public String name;
-  }
-
+public class PutName implements RestModifyView<AccountResource, NameInput> {
   private final Provider<CurrentUser> self;
   private final Realm realm;
   private final PermissionBackend permissionBackend;
@@ -60,7 +55,7 @@
   }
 
   @Override
-  public Response<String> apply(AccountResource rsrc, Input input)
+  public Response<String> apply(AccountResource rsrc, NameInput input)
       throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
           IOException, PermissionBackendException, ConfigInvalidException {
     if (self.get() != rsrc.getUser()) {
@@ -69,11 +64,11 @@
     return apply(rsrc.getUser(), input);
   }
 
-  public Response<String> apply(IdentifiedUser user, Input input)
+  public Response<String> apply(IdentifiedUser user, NameInput input)
       throws MethodNotAllowedException, ResourceNotFoundException, IOException,
           ConfigInvalidException {
     if (input == null) {
-      input = new Input();
+      input = new NameInput();
     }
 
     if (!realm.allowsEdit(AccountFieldName.FULL_NAME)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
index b3f8fc5..5f9ddee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -21,7 +22,6 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.PutPreferred.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -35,7 +35,6 @@
 
 @Singleton
 public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
-  static class Input {}
 
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index a73bdd9..335b0b4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -16,14 +16,13 @@
 
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.extensions.client.AccountFieldName;
+import com.google.gerrit.extensions.common.UsernameInput;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.PutUsername.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -35,11 +34,7 @@
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PutUsername implements RestModifyView<AccountResource, Input> {
-  public static class Input {
-    @DefaultInput public String username;
-  }
-
+public class PutUsername implements RestModifyView<AccountResource, UsernameInput> {
   private final Provider<CurrentUser> self;
   private final ChangeUserName.Factory changeUserNameFactory;
   private final PermissionBackend permissionBackend;
@@ -58,7 +53,7 @@
   }
 
   @Override
-  public String apply(AccountResource rsrc, Input input)
+  public String apply(AccountResource rsrc, UsernameInput input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
           ResourceConflictException, OrmException, IOException, ConfigInvalidException,
           PermissionBackendException {
@@ -71,7 +66,7 @@
     }
 
     if (input == null) {
-      input = new Input();
+      input = new UsernameInput();
     }
 
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
index e35b0c3..8e5582c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,6 +42,7 @@
  */
 public class ExternalIdsBatchUpdate {
   private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsersName;
   private final PersonIdent serverIdent;
   private final ExternalIdCache externalIdCache;
@@ -50,10 +52,12 @@
   @Inject
   public ExternalIdsBatchUpdate(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsersName,
       @GerritPersonIdent PersonIdent serverIdent,
       ExternalIdCache externalIdCache) {
     this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
     this.allUsersName = allUsersName;
     this.serverIdent = serverIdent;
     this.externalIdCache = externalIdCache;
@@ -105,7 +109,17 @@
 
       ObjectId newRev =
           ExternalIdsUpdate.commit(
-              repo, rw, ins, rev, noteMap, commitMessage, serverIdent, serverIdent);
+              allUsersName,
+              repo,
+              rw,
+              ins,
+              rev,
+              noteMap,
+              commitMessage,
+              serverIdent,
+              serverIdent,
+              null,
+              gitRefUpdated);
       externalIdCache.onReplace(rev, newRev, toDelete, toAdd);
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
index e4434fb..00dc05a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
@@ -41,11 +41,13 @@
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -111,6 +113,7 @@
     private final ExternalIds externalIds;
     private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
+    private final GitReferenceUpdated gitRefUpdated;
 
     @Inject
     public Server(
@@ -120,7 +123,8 @@
         MetricMaker metricMaker,
         ExternalIds externalIds,
         ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        GitReferenceUpdated gitRefUpdated) {
       this.repoManager = repoManager;
       this.accountCache = accountCache;
       this.allUsersName = allUsersName;
@@ -128,12 +132,22 @@
       this.externalIds = externalIds;
       this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
+      this.gitRefUpdated = gitRefUpdated;
     }
 
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
-          repoManager, accountCache, allUsersName, metricMaker, externalIds, externalIdCache, i, i);
+          repoManager,
+          accountCache,
+          allUsersName,
+          metricMaker,
+          externalIds,
+          externalIdCache,
+          i,
+          i,
+          null,
+          gitRefUpdated);
     }
   }
 
@@ -154,6 +168,7 @@
     private final ExternalIds externalIds;
     private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
+    private final GitReferenceUpdated gitRefUpdated;
 
     @Inject
     public ServerNoReindex(
@@ -162,19 +177,30 @@
         MetricMaker metricMaker,
         ExternalIds externalIds,
         ExternalIdCache externalIdCache,
-        @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        GitReferenceUpdated gitRefUpdated) {
       this.repoManager = repoManager;
       this.allUsersName = allUsersName;
       this.metricMaker = metricMaker;
       this.externalIds = externalIds;
       this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
+      this.gitRefUpdated = gitRefUpdated;
     }
 
     public ExternalIdsUpdate create() {
       PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
-          repoManager, null, allUsersName, metricMaker, externalIds, externalIdCache, i, i);
+          repoManager,
+          null,
+          allUsersName,
+          metricMaker,
+          externalIds,
+          externalIdCache,
+          i,
+          i,
+          null,
+          gitRefUpdated);
     }
   }
 
@@ -194,6 +220,7 @@
     private final ExternalIdCache externalIdCache;
     private final Provider<PersonIdent> serverIdent;
     private final Provider<IdentifiedUser> identifiedUser;
+    private final GitReferenceUpdated gitRefUpdated;
 
     @Inject
     public User(
@@ -204,7 +231,8 @@
         ExternalIds externalIds,
         ExternalIdCache externalIdCache,
         @GerritPersonIdent Provider<PersonIdent> serverIdent,
-        Provider<IdentifiedUser> identifiedUser) {
+        Provider<IdentifiedUser> identifiedUser,
+        GitReferenceUpdated gitRefUpdated) {
       this.repoManager = repoManager;
       this.accountCache = accountCache;
       this.allUsersName = allUsersName;
@@ -213,9 +241,11 @@
       this.externalIdCache = externalIdCache;
       this.serverIdent = serverIdent;
       this.identifiedUser = identifiedUser;
+      this.gitRefUpdated = gitRefUpdated;
     }
 
     public ExternalIdsUpdate create() {
+      IdentifiedUser user = identifiedUser.get();
       PersonIdent i = serverIdent.get();
       return new ExternalIdsUpdate(
           repoManager,
@@ -224,8 +254,10 @@
           metricMaker,
           externalIds,
           externalIdCache,
-          createPersonIdent(i, identifiedUser.get()),
-          i);
+          createPersonIdent(i, user),
+          i,
+          user,
+          gitRefUpdated);
     }
 
     private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
@@ -253,6 +285,8 @@
   private final ExternalIdCache externalIdCache;
   private final PersonIdent committerIdent;
   private final PersonIdent authorIdent;
+  @Nullable private final IdentifiedUser currentUser;
+  private final GitReferenceUpdated gitRefUpdated;
   private final Runnable afterReadRevision;
   private final Retryer<RefsMetaExternalIdsUpdate> retryer;
   private final Counter0 updateCount;
@@ -265,7 +299,9 @@
       ExternalIds externalIds,
       ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
-      PersonIdent authorIdent) {
+      PersonIdent authorIdent,
+      @Nullable IdentifiedUser currentUser,
+      GitReferenceUpdated gitRefUpdated) {
     this(
         repoManager,
         accountCache,
@@ -275,6 +311,8 @@
         externalIdCache,
         committerIdent,
         authorIdent,
+        currentUser,
+        gitRefUpdated,
         Runnables.doNothing(),
         RETRYER);
   }
@@ -289,6 +327,8 @@
       ExternalIdCache externalIdCache,
       PersonIdent committerIdent,
       PersonIdent authorIdent,
+      @Nullable IdentifiedUser currentUser,
+      GitReferenceUpdated gitRefUpdated,
       Runnable afterReadRevision,
       Retryer<RefsMetaExternalIdsUpdate> retryer) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
@@ -298,6 +338,8 @@
     this.externalIds = checkNotNull(externalIds, "externalIds");
     this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
     this.authorIdent = checkNotNull(authorIdent, "authorIdent");
+    this.currentUser = currentUser;
+    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
     this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
     this.retryer = checkNotNull(retryer, "retryer");
     this.updateCount =
@@ -732,13 +774,26 @@
       NoteMap noteMap,
       UpdatedExternalIds updatedExtIds)
       throws IOException {
-    ObjectId newRev = commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, committerIdent, authorIdent);
+    ObjectId newRev =
+        commit(
+            allUsersName,
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            COMMIT_MSG,
+            committerIdent,
+            authorIdent,
+            currentUser,
+            gitRefUpdated);
     updateCount.increment();
     return RefsMetaExternalIdsUpdate.create(rev, newRev, updatedExtIds);
   }
 
   /** Commits updates to the external IDs. */
   public static ObjectId commit(
+      Project.NameKey project,
       Repository repo,
       RevWalk rw,
       ObjectInserter ins,
@@ -746,7 +801,9 @@
       NoteMap noteMap,
       String commitMessage,
       PersonIdent committerIdent,
-      PersonIdent authorIdent)
+      PersonIdent authorIdent,
+      @Nullable IdentifiedUser user,
+      GitReferenceUpdated gitRefUpdated)
       throws IOException {
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(commitMessage);
@@ -793,6 +850,7 @@
       default:
         throw new IOException("Updating external IDs failed with " + res);
     }
+    gitRefUpdated.fire(project, u, user != null ? user.getAccount() : null);
     return rw.parseCommit(commitId);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index f8539d9..0720a4e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -34,7 +34,9 @@
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.common.SshKeyInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -219,9 +221,9 @@
   public void setActive(boolean active) throws RestApiException {
     try {
       if (active) {
-        putActive.apply(account, new PutActive.Input());
+        putActive.apply(account, new Input());
       } else {
-        deleteActive.apply(account, new DeleteActive.Input());
+        deleteActive.apply(account, new Input());
       }
     } catch (Exception e) {
       throw asRestApiException("Cannot set active", e);
@@ -423,7 +425,7 @@
 
   @Override
   public SshKeyInfo addSshKey(String key) throws RestApiException {
-    AddSshKey.Input in = new AddSshKey.Input();
+    SshKeyInput in = new SshKeyInput();
     in.raw = RawInputUtil.create(key);
     try {
       return addSshKey.apply(account, in).value();
@@ -490,7 +492,7 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(account, new Index.Input());
+      index.apply(account, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index account", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0fba74a..6c7fa6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -41,10 +41,12 @@
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitMessageInput;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.common.TopicInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -435,7 +437,7 @@
 
   @Override
   public void topic(String topic) throws RestApiException {
-    PutTopic.Input in = new PutTopic.Input();
+    TopicInput in = new TopicInput();
     in.topic = topic;
     try {
       putTopic.apply(change, in);
@@ -646,7 +648,7 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(change, new Index.Input());
+      index.apply(change, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index change", e);
     }
@@ -658,9 +660,9 @@
     // StarredChangesUtil.
     try {
       if (ignore) {
-        this.ignore.apply(change, new Ignore.Input());
+        this.ignore.apply(change, new Input());
       } else {
-        unignore.apply(change, new Unignore.Input());
+        unignore.apply(change, new Input());
       }
     } catch (OrmException | IllegalLabelException e) {
       throw asRestApiException("Cannot ignore change", e);
@@ -682,9 +684,9 @@
     // StarredChangesUtil.
     try {
       if (reviewed) {
-        markAsReviewed.apply(change, new MarkAsReviewed.Input());
+        markAsReviewed.apply(change, new Input());
       } else {
-        markAsUnreviewed.apply(change, new MarkAsUnreviewed.Input());
+        markAsUnreviewed.apply(change, new Input());
       }
     } catch (OrmException | IllegalLabelException e) {
       throw asRestApiException(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index d1b57e6..823e771 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -106,7 +107,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteChangeEdit.apply(changeResource, new DeleteChangeEdit.Input());
+      deleteChangeEdit.apply(changeResource, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete change edit", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 65bbc47..7ecfce7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -36,8 +36,10 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
@@ -300,13 +302,13 @@
   @Override
   public void setReviewed(String path, boolean reviewed) throws RestApiException {
     try {
-      RestModifyView<FileResource, Reviewed.Input> view;
+      RestModifyView<FileResource, Input> view;
       if (reviewed) {
         view = putReviewed;
       } else {
         view = deleteReviewed;
       }
-      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Reviewed.Input());
+      view.apply(files.parse(revision, IdString.fromDecoded(path)), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot update reviewed flag", e);
     }
@@ -565,7 +567,7 @@
 
   @Override
   public void description(String description) throws RestApiException {
-    PutDescription.Input in = new PutDescription.Input();
+    DescriptionInput in = new DescriptionInput();
     in.description = description;
     try {
       putDescription.apply(revision, in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 42213f7..39ee086 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -18,9 +18,13 @@
 
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.NameInput;
+import com.google.gerrit.extensions.common.OwnerInput;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.group.AddMembers;
 import com.google.gerrit.server.group.AddSubgroups;
@@ -138,7 +142,7 @@
 
   @Override
   public void name(String name) throws RestApiException {
-    PutName.Input in = new PutName.Input();
+    NameInput in = new NameInput();
     in.name = name;
     try {
       putName.apply(rsrc, in);
@@ -158,7 +162,7 @@
 
   @Override
   public void owner(String owner) throws RestApiException {
-    PutOwner.Input in = new PutOwner.Input();
+    OwnerInput in = new OwnerInput();
     in.owner = owner;
     try {
       putOwner.apply(rsrc, in);
@@ -174,7 +178,7 @@
 
   @Override
   public void description(String description) throws RestApiException {
-    PutDescription.Input in = new PutDescription.Input();
+    DescriptionInput in = new DescriptionInput();
     in.description = description;
     try {
       putDescription.apply(rsrc, in);
@@ -269,7 +273,7 @@
   @Override
   public void index() throws RestApiException {
     try {
-      index.apply(rsrc, new Index.Input());
+      index.apply(rsrc, new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot index group", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
index 2fc2e50..71f7832 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.api.plugins;
 
 import com.google.gerrit.extensions.api.plugins.PluginApi;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.plugins.DisablePlugin;
@@ -57,16 +58,16 @@
 
   @Override
   public void enable() throws RestApiException {
-    enable.apply(resource, new EnablePlugin.Input());
+    enable.apply(resource, new Input());
   }
 
   @Override
   public void disable() throws RestApiException {
-    disable.apply(resource, new DisablePlugin.Input());
+    disable.apply(resource, new Input());
   }
 
   @Override
   public void reload() throws RestApiException {
-    reload.apply(resource, new ReloadPlugin.Input());
+    reload.apply(resource, new Input());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 642791a..aee9b3f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ReflogEntryInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -98,7 +99,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteBranch.apply(resource(), new DeleteBranch.Input());
+      deleteBranch.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete branch", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 702a7e9..9490075 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -22,14 +22,18 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListProjects;
 import com.google.gerrit.server.project.ListProjects.FilterType;
 import com.google.gerrit.server.project.ProjectsCollection;
+import com.google.gerrit.server.project.QueryProjects;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.List;
 import java.util.SortedMap;
 
 @Singleton
@@ -37,15 +41,18 @@
   private final ProjectsCollection projects;
   private final ProjectApiImpl.Factory api;
   private final Provider<ListProjects> listProvider;
+  private final Provider<QueryProjects> queryProvider;
 
   @Inject
   ProjectsImpl(
       ProjectsCollection projects,
       ProjectApiImpl.Factory api,
-      Provider<ListProjects> listProvider) {
+      Provider<ListProjects> listProvider,
+      Provider<QueryProjects> queryProvider) {
     this.projects = projects;
     this.api = api;
     this.listProvider = listProvider;
+    this.queryProvider = queryProvider;
   }
 
   @Override
@@ -124,4 +131,32 @@
 
     return lp.apply();
   }
+
+  @Override
+  public QueryRequest query() {
+    return new QueryRequest() {
+      @Override
+      public List<ProjectInfo> get() throws RestApiException {
+        return ProjectsImpl.this.query(this);
+      }
+    };
+  }
+
+  @Override
+  public QueryRequest query(String query) {
+    return query().withQuery(query);
+  }
+
+  private List<ProjectInfo> query(QueryRequest r) throws RestApiException {
+    try {
+      QueryProjects myQueryProjects = queryProvider.get();
+      myQueryProjects.setQuery(r.getQuery());
+      myQueryProjects.setLimit(r.getLimit());
+      myQueryProjects.setStart(r.getStart());
+
+      return myQueryProjects.apply(TopLevelResource.INSTANCE);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot query projects", e);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 283d117..9f19c6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.CreateTag;
@@ -81,7 +82,7 @@
   @Override
   public void delete() throws RestApiException {
     try {
-      deleteTag.apply(resource(), new DeleteTag.Input());
+      deleteTag.apply(resource(), new Input());
     } catch (Exception e) {
       throw asRestApiException("Cannot delete tag", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index 18d3482..1e3e502 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.common.DiffWebLinkInfo;
 import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AcceptsDelete;
@@ -159,8 +160,7 @@
     }
   }
 
-  public static class DeleteFile implements RestModifyView<ChangeResource, DeleteFile.Input> {
-    public static class Input {}
+  public static class DeleteFile implements RestModifyView<ChangeResource, Input> {
 
     interface Factory {
       DeleteFile create(String path);
@@ -176,7 +176,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, DeleteFile.Input in)
+    public Response<?> apply(ChangeResource rsrc, Input in)
         throws IOException, AuthException, ResourceConflictException, OrmException,
             PermissionBackendException {
       return deleteContent.apply(rsrc, path);
@@ -342,9 +342,7 @@
    * restoring a file to its previous contents.
    */
   @Singleton
-  public static class DeleteContent
-      implements RestModifyView<ChangeEditResource, DeleteContent.Input> {
-    public static class Input {}
+  public static class DeleteContent implements RestModifyView<ChangeEditResource, Input> {
 
     private final ChangeEditModifier editModifier;
     private final GitRepositoryManager repositoryManager;
@@ -356,7 +354,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeEditResource rsrc, DeleteContent.Input input)
+    public Response<?> apply(ChangeEditResource rsrc, Input input)
         throws AuthException, ResourceConflictException, OrmException, IOException,
             PermissionBackendException {
       return apply(rsrc.getChangeResource(), rsrc.getPath());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
index 805512e..a56612c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -126,6 +127,11 @@
   }
 
   private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
-    return permissionBackend.user(user).change(notes).database(db).test(ChangePermission.READ);
+    try {
+      permissionBackend.user(user).change(notes).database(db).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
index d3feb31..8d8d72e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -25,7 +26,6 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.DeleteAssignee.Input;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -45,7 +45,6 @@
 @Singleton
 public class DeleteAssignee
     extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
-  public static class Input {}
 
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ReviewDb> db;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
index af26e8a..69f6178 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
@@ -17,13 +17,13 @@
 import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
 
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.DeleteChange.Input;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -39,7 +39,6 @@
 @Singleton
 public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
     implements UiAction<ChangeResource> {
-  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final Provider<DeleteChangeOp> opProvider;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
index e2e3920..480aca1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.DeleteChangeEdit.Input;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gwtorm.server.OrmException;
@@ -29,7 +29,6 @@
 
 @Singleton
 public class DeleteChangeEdit implements RestModifyView<ChangeResource, Input> {
-  public static class Input {}
 
   private final ChangeEditUtil editUtil;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 68db189..6d82139 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -26,7 +27,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.DeleteDraftComment.Input;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -44,7 +44,6 @@
 @Singleton
 public class DeleteDraftComment
     extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
-  static class Input {}
 
   private final Provider<ReviewDb> db;
   private final CommentsUtil commentsUtil;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
index 46dabdf..c2c2d1f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -30,12 +31,9 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Ignore
-    implements RestModifyView<ChangeResource, Ignore.Input>, UiAction<ChangeResource> {
+public class Ignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Ignore.class);
 
-  public static class Input {}
-
   private final StarredChangesUtil stars;
 
   @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
index 7c4d158..85e13cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.Index.Input;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -34,7 +34,6 @@
 
 @Singleton
 public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
-  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final PermissionBackend permissionBackend;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java
index 265b2b0..9e77805 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -31,11 +32,9 @@
 
 @Singleton
 public class MarkAsReviewed
-    implements RestModifyView<ChangeResource, MarkAsReviewed.Input>, UiAction<ChangeResource> {
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(MarkAsReviewed.class);
 
-  public static class Input {}
-
   private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
   private final StarredChangesUtil stars;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java
index 6de84ee..436548b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -30,11 +31,9 @@
 
 @Singleton
 public class MarkAsUnreviewed
-    implements RestModifyView<ChangeResource, MarkAsUnreviewed.Input>, UiAction<ChangeResource> {
+    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(MarkAsUnreviewed.class);
 
-  public static class Input {}
-
   private final Provider<ReviewDb> dbProvider;
   private final ChangeData.Factory changeDataFactory;
   private final StarredChangesUtil stars;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
index 4c9cf23..0d932ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
@@ -16,7 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -42,16 +42,12 @@
 
 @Singleton
 public class PutDescription
-    extends RetryingRestModifyView<RevisionResource, PutDescription.Input, Response<String>>
+    extends RetryingRestModifyView<RevisionResource, DescriptionInput, Response<String>>
     implements UiAction<RevisionResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
 
-  public static class Input {
-    @DefaultInput public String description;
-  }
-
   @Inject
   PutDescription(
       Provider<ReviewDb> dbProvider,
@@ -66,11 +62,11 @@
 
   @Override
   protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, RevisionResource rsrc, Input input)
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DescriptionInput input)
       throws UpdateException, RestApiException, PermissionBackendException {
     rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
 
-    Op op = new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
+    Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().getId());
     try (BatchUpdate u =
         updateFactory.create(
             dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
@@ -83,13 +79,13 @@
   }
 
   private class Op implements BatchUpdateOp {
-    private final Input input;
+    private final DescriptionInput input;
     private final PatchSet.Id psId;
 
     private String oldDescription;
     private String newDescription;
 
-    Op(Input input, PatchSet.Id psId) {
+    Op(DescriptionInput input, PatchSet.Id psId) {
       this.input = input;
       this.psId = psId;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index 8b5608b..33b3d7e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -16,8 +16,8 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.common.TopicInput;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -26,7 +26,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.change.PutTopic.Input;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.permissions.ChangePermission;
@@ -44,16 +43,12 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic extends RetryingRestModifyView<ChangeResource, Input, Response<String>>
+public class PutTopic extends RetryingRestModifyView<ChangeResource, TopicInput, Response<String>>
     implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
   private final TopicEdited topicEdited;
 
-  public static class Input {
-    @DefaultInput public String topic;
-  }
-
   @Inject
   PutTopic(
       Provider<ReviewDb> dbProvider,
@@ -68,7 +63,7 @@
 
   @Override
   protected Response<String> applyImpl(
-      BatchUpdate.Factory updateFactory, ChangeResource req, Input input)
+      BatchUpdate.Factory updateFactory, ChangeResource req, TopicInput input)
       throws UpdateException, RestApiException, PermissionBackendException {
     req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
 
@@ -79,7 +74,7 @@
           String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
-    Op op = new Op(input != null ? input : new Input());
+    Op op = new Op(input != null ? input : new TopicInput());
     try (BatchUpdate u =
         updateFactory.create(
             dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
@@ -90,13 +85,13 @@
   }
 
   private class Op implements BatchUpdateOp {
-    private final Input input;
+    private final TopicInput input;
 
     private Change change;
     private String oldTopicName;
     private String newTopicName;
 
-    Op(Input input) {
+    Op(TopicInput input) {
       this.input = input;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
index 38a695a..2909827 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -68,8 +69,7 @@
   }
 
   @Singleton
-  public static class Rebase implements RestModifyView<ChangeResource, Rebase.Input> {
-    public static class Input {}
+  public static class Rebase implements RestModifyView<ChangeResource, Input> {
 
     private final GitRepositoryManager repositoryManager;
     private final ChangeEditModifier editModifier;
@@ -81,7 +81,7 @@
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, Rebase.Input in)
+    public Response<?> apply(ChangeResource rsrc, Input in)
         throws AuthException, ResourceConflictException, IOException, OrmException,
             PermissionBackendException {
       Project.NameKey project = rsrc.getProject();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
index 682b45f..a3ed670 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -23,7 +24,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.change.Rebuild.Input;
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -40,7 +40,6 @@
 
 @Singleton
 public class Rebuild implements RestModifyView<ChangeResource, Input> {
-  public static class Input {}
 
   private final Provider<ReviewDb> db;
   private final NotesMigration migration;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
index 0d25d35..c412adf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewed.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -22,7 +23,6 @@
 import com.google.inject.Singleton;
 
 public class Reviewed {
-  public static class Input {}
 
   @Singleton
   public static class PutReviewed implements RestModifyView<FileResource, Input> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
index 2bad16c..39a82d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
@@ -26,12 +27,9 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Unignore
-    implements RestModifyView<ChangeResource, Unignore.Input>, UiAction<ChangeResource> {
+public class Unignore implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Unignore.class);
 
-  public static class Input {}
-
   private final StarredChangesUtil stars;
 
   @Inject
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
index 29ca20f..d20589a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DeleteTask.java
@@ -19,16 +19,15 @@
 import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.config.DeleteTask.Input;
 import com.google.gerrit.server.git.WorkQueue.Task;
 import com.google.inject.Singleton;
 
 @Singleton
 @RequiresAnyCapability({KILL_TASK, MAINTAIN_SERVER})
 public class DeleteTask implements RestModifyView<TaskResource, Input> {
-  public static class Input {}
 
   @Override
   public Response<?> apply(TaskResource rsrc, Input input) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
index 366dae1..5d2ec36 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
@@ -18,11 +18,11 @@
 import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
 
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.FlushCache.Input;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -33,7 +33,6 @@
 @RequiresAnyCapability({FLUSH_CACHES, MAINTAIN_SERVER})
 @Singleton
 public class FlushCache implements RestModifyView<CacheResource, Input> {
-  public static class Input {}
 
   public static final String WEB_SESSIONS = "web_sessions";
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 50f4975..23f2526 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -34,8 +34,6 @@
 import java.util.EnumSet;
 import java.util.SortedSet;
 import java.util.TreeSet;
-import java.util.concurrent.locks.Lock;
-import java.util.concurrent.locks.ReentrantLock;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ConfigConstants;
@@ -111,8 +109,6 @@
   }
 
   private final Path basePath;
-  private final Lock namesUpdateLock;
-  private volatile SortedSet<Project.NameKey> names = new TreeSet<>();
 
   @Inject
   LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
@@ -120,8 +116,6 @@
     if (basePath == null) {
       throw new IllegalStateException("gerrit.basePath must be configured");
     }
-
-    namesUpdateLock = new ReentrantLock(true /* fair */);
   }
 
   /**
@@ -144,32 +138,7 @@
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
-    File gitDir = path.resolve(name.get()).toFile();
-    if (!names.contains(name)) {
-      // The this.names list does not hold the project-name but it can still exist
-      // on disk; for instance when the project has been created directly on the
-      // file-system through replication.
-      //
-      if (!name.get().endsWith(Constants.DOT_GIT_EXT)) {
-        if (FileKey.resolve(gitDir, FS.DETECTED) != null) {
-          onCreateProject(name);
-        } else {
-          throw new RepositoryNotFoundException(gitDir);
-        }
-      } else {
-        final File directory = gitDir;
-        if (FileKey.isGitRepository(new File(directory, Constants.DOT_GIT), FS.DETECTED)) {
-          onCreateProject(name);
-        } else if (FileKey.isGitRepository(
-            new File(directory.getParentFile(), directory.getName() + Constants.DOT_GIT_EXT),
-            FS.DETECTED)) {
-          onCreateProject(name);
-        } else {
-          throw new RepositoryNotFoundException(gitDir);
-        }
-      }
-    }
-    final FileKey loc = FileKey.lenient(gitDir, FS.DETECTED);
+    FileKey loc = FileKey.lenient(path.resolve(name.get()).toFile(), FS.DETECTED);
     try {
       return RepositoryCache.open(loc);
     } catch (IOException e1) {
@@ -189,26 +158,24 @@
     }
 
     File dir = FileKey.resolve(path.resolve(name.get()).toFile(), FS.DETECTED);
-    FileKey loc;
     if (dir != null) {
       // Already exists on disk, use the repository we found.
       //
       Project.NameKey onDiskName = getProjectName(path, dir.getCanonicalFile().toPath());
-      onCreateProject(onDiskName);
 
-      loc = FileKey.exact(dir, FS.DETECTED);
-
-      if (!names.contains(name)) {
+      if (!onDiskName.equals(name)) {
         throw new RepositoryCaseMismatchException(name);
       }
-    } else {
-      // It doesn't exist under any of the standard permutations
-      // of the repository name, so prefer the standard bare name.
-      //
-      String n = name.get() + Constants.DOT_GIT_EXT;
-      loc = FileKey.exact(path.resolve(n).toFile(), FS.DETECTED);
+
+      throw new IllegalStateException("Repository already exists: " + name);
     }
 
+    // It doesn't exist under any of the standard permutations
+    // of the repository name, so prefer the standard bare name.
+    //
+    String n = name.get() + Constants.DOT_GIT_EXT;
+    FileKey loc = FileKey.exact(path.resolve(n).toFile(), FS.DETECTED);
+
     try {
       Repository db = RepositoryCache.open(loc, false);
       db.create(true /* bare */);
@@ -231,8 +198,6 @@
                 "Failed to create ref log for %s in repository %s", RefNames.REFS_CONFIG, name));
       }
 
-      onCreateProject(name);
-
       return db;
     } catch (IOException e1) {
       final RepositoryNotFoundException e2;
@@ -242,17 +207,6 @@
     }
   }
 
-  private void onCreateProject(Project.NameKey newProjectName) {
-    namesUpdateLock.lock();
-    try {
-      SortedSet<Project.NameKey> n = new TreeSet<>(names);
-      n.add(newProjectName);
-      names = Collections.unmodifiableSortedSet(n);
-    } finally {
-      namesUpdateLock.unlock();
-    }
-  }
-
   private boolean isUnreasonableName(Project.NameKey nameKey) {
     final String name = nameKey.get();
 
@@ -281,21 +235,9 @@
 
   @Override
   public SortedSet<Project.NameKey> list() {
-    // The results of this method are cached by ProjectCacheImpl. Control only
-    // enters here if the cache was flushed by the administrator to force
-    // scanning the filesystem.
-    // Don't rely on the cached names collection but update it to contain
-    // the set of found project names
     ProjectVisitor visitor = new ProjectVisitor(basePath);
     scanProjects(visitor);
-
-    namesUpdateLock.lock();
-    try {
-      names = Collections.unmodifiableSortedSet(visitor.found);
-    } finally {
-      namesUpdateLock.unlock();
-    }
-    return names;
+    return Collections.unmodifiableSortedSet(visitor.found);
   }
 
   protected void scanProjects(ProjectVisitor visitor) {
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 b7aa416..aaed2e7 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
@@ -669,6 +669,11 @@
       }
       addMessage("");
     }
+
+    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
+    if (magicBranch != null && magicBranch.publish) {
+      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
+    }
   }
 
   private void insertChangesAndPatchSets() {
@@ -1165,6 +1170,8 @@
     )
     boolean draft;
 
+    boolean publish;
+
     @Option(name = "--private", usage = "mark new/updated change as private")
     boolean isPrivate;
 
@@ -1289,6 +1296,7 @@
         NotesMigration notesMigration) {
       this.cmd = cmd;
       this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
+      this.publish = cmd.getRefName().startsWith(MagicBranch.NEW_PUBLISH_CHANGE);
       this.labelTypes = labelTypes;
       this.notesMigration = notesMigration;
       GeneralPreferencesInfo prefs = user.getAccount().getGeneralPreferencesInfo();
@@ -2563,7 +2571,11 @@
       }
       if (isConfig(cmd)) {
         logDebug("Reloading project in cache");
-        projectCache.evict(project);
+        try {
+          projectCache.evict(project);
+        } catch (IOException e) {
+          log.warn("Cannot evict from project cache, name key: " + project.getName(), e);
+        }
         ProjectState ps = projectCache.get(project.getNameKey());
         try {
           logDebug("Updating project description");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
index b6bcb3b..24b3f36 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
@@ -204,8 +204,7 @@
     return result;
   }
 
-  static class PutMember implements RestModifyView<GroupResource, PutMember.Input> {
-    static class Input {}
+  static class PutMember implements RestModifyView<GroupResource, Input> {
 
     private final AddMembers put;
     private final String id;
@@ -216,7 +215,7 @@
     }
 
     @Override
-    public AccountInfo apply(GroupResource resource, PutMember.Input input)
+    public AccountInfo apply(GroupResource resource, Input input)
         throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
             IOException, ConfigInvalidException {
       AddMembers.Input in = new AddMembers.Input();
@@ -234,7 +233,7 @@
   }
 
   @Singleton
-  static class UpdateMember implements RestModifyView<MemberResource, PutMember.Input> {
+  static class UpdateMember implements RestModifyView<MemberResource, Input> {
     private final GetMember get;
 
     @Inject
@@ -243,7 +242,7 @@
     }
 
     @Override
-    public AccountInfo apply(MemberResource resource, PutMember.Input input) throws OrmException {
+    public AccountInfo apply(MemberResource resource, Input input) throws OrmException {
       // Do nothing, the user is already a member.
       return get.apply(resource);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java
index 2ce168f..f60a8ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java
@@ -114,8 +114,7 @@
     return result;
   }
 
-  static class PutSubgroup implements RestModifyView<GroupResource, PutSubgroup.Input> {
-    static class Input {}
+  static class PutSubgroup implements RestModifyView<GroupResource, Input> {
 
     private final AddSubgroups addSubgroups;
     private final String id;
@@ -144,7 +143,7 @@
   }
 
   @Singleton
-  static class UpdateSubgroup implements RestModifyView<SubgroupResource, PutSubgroup.Input> {
+  static class UpdateSubgroup implements RestModifyView<SubgroupResource, Input> {
     private final Provider<GetSubgroup> get;
 
     @Inject
@@ -153,7 +152,7 @@
     }
 
     @Override
-    public GroupInfo apply(SubgroupResource resource, PutSubgroup.Input input) throws OrmException {
+    public GroupInfo apply(SubgroupResource resource, Input input) throws OrmException {
       // Do nothing, the group is already included.
       return get.get().apply(resource);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
index 1069e1c..64de014 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
@@ -82,8 +82,7 @@
   }
 
   @Singleton
-  static class DeleteMember implements RestModifyView<MemberResource, DeleteMember.Input> {
-    static class Input {}
+  static class DeleteMember implements RestModifyView<MemberResource, Input> {
 
     private final Provider<DeleteMembers> delete;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java
index 14df51b..43c7d59 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java
@@ -82,8 +82,7 @@
   }
 
   @Singleton
-  static class DeleteSubgroup implements RestModifyView<SubgroupResource, DeleteSubgroup.Input> {
-    static class Input {}
+  static class DeleteSubgroup implements RestModifyView<SubgroupResource, Input> {
 
     private final Provider<DeleteSubgroups> delete;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java
index a2660f2..233f36b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java
@@ -47,6 +47,29 @@
 public class Groups {
 
   /**
+   * Returns the {@code AccountGroup} for the specified ID if it exists.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupId the ID of the group
+   * @return the found {@code AccountGroup} if it exists, or else an empty {@code Optional}
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   */
+  public Optional<InternalGroup> getGroup(ReviewDb db, AccountGroup.Id groupId)
+      throws OrmException, NoSuchGroupException {
+    Optional<AccountGroup> accountGroup = Optional.ofNullable(db.accountGroups().get(groupId));
+
+    if (!accountGroup.isPresent()) {
+      return Optional.empty();
+    }
+
+    AccountGroup.UUID groupUuid = accountGroup.get().getGroupUUID();
+    ImmutableSet<Account.Id> members = getMembers(db, groupUuid).collect(toImmutableSet());
+    ImmutableSet<AccountGroup.UUID> subgroups =
+        getSubgroups(db, groupUuid).collect(toImmutableSet());
+    return accountGroup.map(group -> InternalGroup.create(group, members, subgroups));
+  }
+
+  /**
    * Returns the {@code InternalGroup} for the specified UUID if it exists.
    *
    * @param db the {@code ReviewDb} instance to use for lookups
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
index b61f954..b2845fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.Index.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -28,7 +28,6 @@
 
 @Singleton
 public class Index implements RestModifyView<GroupResource, Input> {
-  public static class Input {}
 
   private final GroupCache groupCache;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
index 3d6feea..757ad31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
@@ -17,15 +17,14 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.PutDescription.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -34,11 +33,7 @@
 import java.util.Objects;
 
 @Singleton
-public class PutDescription implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String description;
-  }
-
+public class PutDescription implements RestModifyView<GroupResource, DescriptionInput> {
   private final Provider<ReviewDb> db;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
@@ -50,11 +45,11 @@
   }
 
   @Override
-  public Response<String> apply(GroupResource resource, Input input)
+  public Response<String> apply(GroupResource resource, DescriptionInput input)
       throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
           IOException {
     if (input == null) {
-      input = new Input(); // Delete would set description to null.
+      input = new DescriptionInput(); // Delete would set description to null.
     }
 
     GroupDescription.Internal internalGroup =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
index 75a7eb5..3e7fd41 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
@@ -18,16 +18,15 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.PutName.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,11 +34,7 @@
 import java.io.IOException;
 
 @Singleton
-public class PutName implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String name;
-  }
-
+public class PutName implements RestModifyView<GroupResource, NameInput> {
   private final Provider<ReviewDb> db;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
 
@@ -50,7 +45,7 @@
   }
 
   @Override
-  public String apply(GroupResource rsrc, Input input)
+  public String apply(GroupResource rsrc, NameInput input)
       throws MethodNotAllowedException, AuthException, BadRequestException,
           ResourceConflictException, ResourceNotFoundException, OrmException, IOException {
     GroupDescription.Internal internalGroup =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
index 20e1dbe..7723b27 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
@@ -18,16 +18,15 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.OwnerInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.PutOwner.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -35,11 +34,7 @@
 import java.io.IOException;
 
 @Singleton
-public class PutOwner implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput public String owner;
-  }
-
+public class PutOwner implements RestModifyView<GroupResource, OwnerInput> {
   private final GroupsCollection groupsCollection;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final Provider<ReviewDb> db;
@@ -58,7 +53,7 @@
   }
 
   @Override
-  public GroupInfo apply(GroupResource resource, Input input)
+  public GroupInfo apply(GroupResource resource, OwnerInput input)
       throws ResourceNotFoundException, MethodNotAllowedException, AuthException,
           BadRequestException, UnprocessableEntityException, OrmException, IOException {
     GroupDescription.Internal internalGroup =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
index 481726b..85d6a7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
@@ -23,6 +23,8 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.DummyChangeIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.gerrit.server.project.ProjectData;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 
@@ -48,6 +50,13 @@
     }
   }
 
+  private static class DummyProjectIndexFactory implements ProjectIndex.Factory {
+    @Override
+    public ProjectIndex create(Schema<ProjectData> schema) {
+      throw new UnsupportedOperationException();
+    }
+  }
+
   @Override
   protected void configure() {
     install(new IndexModule(1));
@@ -56,5 +65,6 @@
     bind(AccountIndex.Factory.class).toInstance(new DummyAccountIndexFactory());
     bind(ChangeIndex.Factory.class).toInstance(new DummyChangeIndexFactory());
     bind(GroupIndex.Factory.class).toInstance(new DummyGroupIndexFactory());
+    bind(ProjectIndex.Factory.class).toInstance(new DummyProjectIndexFactory());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index 6854a87..8c9a964 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -45,6 +45,12 @@
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.GroupIndexerImpl;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.server.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.index.project.ProjectIndexDefinition;
+import com.google.gerrit.server.index.project.ProjectIndexRewriter;
+import com.google.gerrit.server.index.project.ProjectIndexer;
+import com.google.gerrit.server.index.project.ProjectIndexerImpl;
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.Provides;
@@ -70,7 +76,8 @@
       ImmutableList.<SchemaDefinitions<?>>of(
           AccountSchemaDefinitions.INSTANCE,
           ChangeSchemaDefinitions.INSTANCE,
-          GroupSchemaDefinitions.INSTANCE);
+          GroupSchemaDefinitions.INSTANCE,
+          ProjectSchemaDefinitions.INSTANCE);
 
   /** Type of secondary index. */
   public static IndexType getIndexType(Injector injector) {
@@ -112,14 +119,22 @@
     listener().to(GroupIndexCollection.class);
     factory(GroupIndexerImpl.Factory.class);
 
+    bind(ProjectIndexRewriter.class);
+    bind(ProjectIndexCollection.class);
+    listener().to(ProjectIndexCollection.class);
+    factory(ProjectIndexerImpl.Factory.class);
+
     DynamicSet.setOf(binder(), OnlineUpgradeListener.class);
   }
 
   @Provides
   Collection<IndexDefinition<?, ?, ?>> getIndexDefinitions(
-      AccountIndexDefinition accounts, ChangeIndexDefinition changes, GroupIndexDefinition groups) {
+      AccountIndexDefinition accounts,
+      ChangeIndexDefinition changes,
+      GroupIndexDefinition groups,
+      ProjectIndexDefinition projects) {
     Collection<IndexDefinition<?, ?, ?>> result =
-        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes);
+        ImmutableList.<IndexDefinition<?, ?, ?>>of(accounts, groups, changes, projects);
     Set<String> expected =
         FluentIterable.from(ALL_SCHEMA_DEFS).transform(SchemaDefinitions::getName).toSet();
     Set<String> actual = FluentIterable.from(result).transform(IndexDefinition::getName).toSet();
@@ -156,6 +171,13 @@
 
   @Provides
   @Singleton
+  ProjectIndexer getProjectIndexer(
+      ProjectIndexerImpl.Factory factory, ProjectIndexCollection indexes) {
+    return factory.create(indexes);
+  }
+
+  @Provides
+  @Singleton
   @IndexExecutor(INTERACTIVE)
   ListeningExecutorService getInteractiveIndexExecutor(
       @GerritServerConfig Config config, WorkQueue workQueue) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
index ea9900b..b37ed61 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.project.ProjectField;
 import com.google.gerrit.server.query.change.SingleGroupUser;
 import java.io.IOException;
 import java.util.Set;
@@ -94,6 +95,13 @@
     return user.toString();
   }
 
+  public static Set<String> projectFields(QueryOptions opts) {
+    Set<String> fs = opts.fields();
+    return fs.contains(ProjectField.NAME.getName())
+        ? fs
+        : Sets.union(fs, ImmutableSet.of(ProjectField.NAME.getName()));
+  }
+
   private IndexUtils() {
     // hide default constructor
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index 2ab5c55..2e1da65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -88,7 +88,7 @@
   public void onGitReferenceUpdated(Event event) {
     if (allUsersName.get().equals(event.getProjectName())) {
       Account.Id accountId = Account.Id.fromRef(event.getRefName());
-      if (accountId != null) {
+      if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
         try {
           accountCache.evict(accountId);
         } catch (IOException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
new file mode 100644
index 0000000..a53434e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -0,0 +1,112 @@
+// 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.project;
+
+import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
+
+import com.google.common.base.Stopwatch;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.SiteIndexer;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AllProjectsIndexer extends SiteIndexer<Project.NameKey, ProjectData, ProjectIndex> {
+
+  private static final Logger log = LoggerFactory.getLogger(AllProjectsIndexer.class);
+
+  private final ListeningExecutorService executor;
+  private final ProjectCache projectCache;
+
+  @Inject
+  AllProjectsIndexer(
+      @IndexExecutor(BATCH) ListeningExecutorService executor, ProjectCache projectCache) {
+    this.executor = executor;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public SiteIndexer.Result indexAll(final ProjectIndex index) {
+    ProgressMonitor progress = new TextProgressMonitor(new PrintWriter(progressOut));
+    progress.start(2);
+    List<Project.NameKey> names = collectProjects(progress);
+    return reindexProjects(index, names, progress);
+  }
+
+  private SiteIndexer.Result reindexProjects(
+      ProjectIndex index, List<Project.NameKey> names, ProgressMonitor progress) {
+    progress.beginTask("Reindexing projects", names.size());
+    List<ListenableFuture<?>> futures = new ArrayList<>(names.size());
+    AtomicBoolean ok = new AtomicBoolean(true);
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
+    Stopwatch sw = Stopwatch.createStarted();
+    for (Project.NameKey name : names) {
+      String desc = "project " + name;
+      ListenableFuture<?> future =
+          executor.submit(
+              () -> {
+                try {
+                  projectCache.evict(name);
+                  index.replace(projectCache.get(name).toProjectData());
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
+                }
+                return null;
+              });
+      addErrorListener(future, desc, progress, ok);
+      futures.add(future);
+    }
+
+    try {
+      Futures.successfulAsList(futures).get();
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Error waiting on project futures", e);
+      return new SiteIndexer.Result(sw, false, 0, 0);
+    }
+
+    progress.endTask();
+    return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
+  }
+
+  private List<Project.NameKey> collectProjects(ProgressMonitor progress) {
+    progress.beginTask("Collecting projects", ProgressMonitor.UNKNOWN);
+    List<Project.NameKey> names = new ArrayList<>();
+    for (Project.NameKey nameKey : projectCache.all()) {
+      names.add(nameKey);
+    }
+    progress.endTask();
+    return names;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/IndexedProjectQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/IndexedProjectQuery.java
new file mode 100644
index 0000000..41bff05
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/IndexedProjectQuery.java
@@ -0,0 +1,34 @@
+// 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.project;
+
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexedQuery;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectData;
+
+public class IndexedProjectQuery extends IndexedQuery<Project.NameKey, ProjectData>
+    implements DataSource<ProjectData> {
+
+  public IndexedProjectQuery(
+      Index<Project.NameKey, ProjectData> index, Predicate<ProjectData> pred, QueryOptions opts)
+      throws QueryParseException {
+    super(index, pred, opts.convertForBackend());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectField.java
new file mode 100644
index 0000000..c4f8e9e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectField.java
@@ -0,0 +1,44 @@
+// 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.project;
+
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.fullText;
+import static com.google.gerrit.index.FieldDef.prefix;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.server.project.ProjectData;
+
+/** Index schema for projects. */
+public class ProjectField {
+
+  public static final FieldDef<ProjectData, String> NAME =
+      exact("name").stored().build(p -> p.getProject().getName());
+
+  public static final FieldDef<ProjectData, String> DESCRIPTION =
+      fullText("description").build(p -> p.getProject().getDescription());
+
+  public static final FieldDef<ProjectData, String> PARENT_NAME =
+      exact("parent_name").build(p -> p.getProject().getParentName());
+
+  public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
+      prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+
+  public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
+      exact("ancestor_name")
+          .buildRepeatable(p -> Iterables.transform(p.getAncestors(), n -> n.get()));
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndex.java
new file mode 100644
index 0000000..5fbdf04
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndex.java
@@ -0,0 +1,33 @@
+// 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.project;
+
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gerrit.server.query.project.ProjectPredicates;
+
+public interface ProjectIndex extends Index<Project.NameKey, ProjectData> {
+
+  public interface Factory
+      extends IndexDefinition.IndexFactory<Project.NameKey, ProjectData, ProjectIndex> {}
+
+  @Override
+  default Predicate<ProjectData> keyPredicate(Project.NameKey nameKey) {
+    return ProjectPredicates.name(nameKey);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java
new file mode 100644
index 0000000..eeebfa1
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexCollection.java
@@ -0,0 +1,29 @@
+// 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.project;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ProjectIndexCollection
+    extends IndexCollection<Project.NameKey, ProjectData, ProjectIndex> {
+
+  @VisibleForTesting
+  public ProjectIndexCollection() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
new file mode 100644
index 0000000..301f209
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexDefinition.java
@@ -0,0 +1,33 @@
+// 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.project;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+
+public class ProjectIndexDefinition
+    extends IndexDefinition<Project.NameKey, ProjectData, ProjectIndex> {
+
+  @Inject
+  ProjectIndexDefinition(
+      ProjectIndexCollection indexCollection,
+      ProjectIndex.Factory indexFactory,
+      @Nullable AllProjectsIndexer allProjectsIndexer) {
+    super(ProjectSchemaDefinitions.INSTANCE, indexCollection, indexFactory, allProjectsIndexer);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.java
new file mode 100644
index 0000000..41d8820
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexRewriter.java
@@ -0,0 +1,43 @@
+// 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.project;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ProjectIndexRewriter implements IndexRewriter<ProjectData> {
+  private final ProjectIndexCollection indexes;
+
+  @Inject
+  ProjectIndexRewriter(ProjectIndexCollection indexes) {
+    this.indexes = indexes;
+  }
+
+  @Override
+  public Predicate<ProjectData> rewrite(Predicate<ProjectData> in, QueryOptions opts)
+      throws QueryParseException {
+    ProjectIndex index = indexes.getSearchIndex();
+    checkNotNull(index, "no active search index configured for projects");
+    return new IndexedProjectQuery(index, in, opts);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexer.java
new file mode 100644
index 0000000..e8a8183
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexer.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.project;
+
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+
+public interface ProjectIndexer {
+
+  /**
+   * Synchronously index a project.
+   *
+   * @param nameKey name key of project to index.
+   */
+  void index(Project.NameKey nameKey) throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
new file mode 100644
index 0000000..2a51f32
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectIndexerImpl.java
@@ -0,0 +1,86 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.events.ProjectIndexedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+
+public class ProjectIndexerImpl implements ProjectIndexer {
+  public interface Factory {
+    ProjectIndexerImpl create(ProjectIndexCollection indexes);
+
+    ProjectIndexerImpl create(@Nullable ProjectIndex index);
+  }
+
+  private final ProjectCache projectCache;
+  private final DynamicSet<ProjectIndexedListener> indexedListener;
+  private final ProjectIndexCollection indexes;
+  private final ProjectIndex index;
+
+  @AssistedInject
+  ProjectIndexerImpl(
+      ProjectCache projectCache,
+      DynamicSet<ProjectIndexedListener> indexedListener,
+      @Assisted ProjectIndexCollection indexes) {
+    this.projectCache = projectCache;
+    this.indexedListener = indexedListener;
+    this.indexes = indexes;
+    this.index = null;
+  }
+
+  @AssistedInject
+  ProjectIndexerImpl(
+      ProjectCache projectCache,
+      DynamicSet<ProjectIndexedListener> indexedListener,
+      @Assisted ProjectIndex index) {
+    this.projectCache = projectCache;
+    this.indexedListener = indexedListener;
+    this.indexes = null;
+    this.index = index;
+  }
+
+  @Override
+  public void index(Project.NameKey nameKey) throws IOException {
+    for (Index<?, ProjectData> i : getWriteIndexes()) {
+      i.replace(projectCache.get(nameKey).toProjectData());
+    }
+    fireProjectIndexedEvent(nameKey.get());
+  }
+
+  private void fireProjectIndexedEvent(String name) {
+    for (ProjectIndexedListener listener : indexedListener) {
+      listener.onProjectIndexed(name);
+    }
+  }
+
+  private Collection<ProjectIndex> getWriteIndexes() {
+    if (indexes != null) {
+      return indexes.getWriteIndexes();
+    }
+
+    return index != null ? Collections.singleton(index) : ImmutableSet.of();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.java
new file mode 100644
index 0000000..ccece02
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/project/ProjectSchemaDefinitions.java
@@ -0,0 +1,38 @@
+// 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.project;
+
+import static com.google.gerrit.index.SchemaUtil.schema;
+
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaDefinitions;
+import com.google.gerrit.server.project.ProjectData;
+
+public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
+
+  static final Schema<ProjectData> V1 =
+      schema(
+          ProjectField.NAME,
+          ProjectField.DESCRIPTION,
+          ProjectField.PARENT_NAME,
+          ProjectField.NAME_PART,
+          ProjectField.ANCESTOR_NAME);
+
+  public static final ProjectSchemaDefinitions INSTANCE = new ProjectSchemaDefinitions();
+
+  private ProjectSchemaDefinitions() {
+    super("projects", ProjectData.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
index 43813f8..777624a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
@@ -86,6 +87,7 @@
   private static final Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
   private final Project.NameKey projectName;
   private final String refName;
   private final Seed seed;
@@ -103,16 +105,26 @@
 
   public RepoSequence(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       Project.NameKey projectName,
       String name,
       Seed seed,
       int batchSize) {
-    this(repoManager, projectName, name, seed, batchSize, Runnables.doNothing(), RETRYER);
+    this(
+        repoManager,
+        gitRefUpdated,
+        projectName,
+        name,
+        seed,
+        batchSize,
+        Runnables.doNothing(),
+        RETRYER);
   }
 
   @VisibleForTesting
   RepoSequence(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       Project.NameKey projectName,
       String name,
       Seed seed,
@@ -120,6 +132,7 @@
       Runnable afterReadRef,
       Retryer<RefUpdate.Result> retryer) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
     this.projectName = checkNotNull(projectName, "projectName");
 
     checkArgument(
@@ -213,11 +226,15 @@
   }
 
   private void checkResult(RefUpdate.Result result) throws OrmException {
-    if (result != RefUpdate.Result.NEW && result != RefUpdate.Result.FORCED) {
+    if (!refUpdated(result)) {
       throw new OrmException("failed to update " + refName + ": " + result);
     }
   }
 
+  private boolean refUpdated(RefUpdate.Result result) {
+    return result == RefUpdate.Result.NEW || result == RefUpdate.Result.FORCED;
+  }
+
   private class TryAcquire implements Callable<RefUpdate.Result> {
     private final Repository repo;
     private final RevWalk rw;
@@ -275,7 +292,11 @@
     }
     ru.setNewObjectId(newId);
     ru.setForceUpdate(true); // Required for non-commitish updates.
-    return ru.update(rw);
+    RefUpdate.Result result = ru.update(rw);
+    if (refUpdated(result)) {
+      gitRefUpdated.fire(projectName, ru, null);
+    }
+    return result;
   }
 
   public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index 7913e82..f302177 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.GerritServerConfigProvider;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.git.WorkQueue;
@@ -505,6 +506,7 @@
       RepoSequence seq =
           new RepoSequence(
               repoManager,
+              GitReferenceUpdated.DISABLED,
               allProjects,
               Sequences.NAME_CHANGES,
               // If sequenceGap is 0, this writes into the sequence ref the same ID that is returned
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
index a2da580..266350f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -15,32 +15,41 @@
 package com.google.gerrit.server.plugins;
 
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.DisablePlugin.Input;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class DisablePlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
 
   private final PluginLoader loader;
+  private final Provider<IdentifiedUser> user;
+  private final PermissionBackend permissionBackend;
 
   @Inject
-  DisablePlugin(PluginLoader loader) {
+  DisablePlugin(
+      PluginLoader loader, Provider<IdentifiedUser> user, PermissionBackend permissionBackend) {
     this.loader = loader;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input) throws MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
+  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+    } catch (PermissionBackendException e) {
+      throw new RestApiException("Could not check permission", e);
     }
+    loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     loader.disablePlugins(ImmutableSet.of(name));
     return ListPlugins.toPluginInfo(loader.get(name));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
index f29e36b..569bc39 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
@@ -17,11 +17,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.EnablePlugin.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.PrintWriter;
@@ -30,7 +30,6 @@
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class EnablePlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
 
   private final PluginLoader loader;
 
@@ -40,11 +39,8 @@
   }
 
   @Override
-  public PluginInfo apply(PluginResource resource, Input input)
-      throws ResourceConflictException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote plugin administration is disabled");
-    }
+  public PluginInfo apply(PluginResource resource, Input input) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
     String name = resource.getName();
     try {
       loader.enablePlugins(ImmutableSet.of(name));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
index 531e9ac..ee9099e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.extensions.common.InstallPluginInput;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.inject.Inject;
@@ -56,10 +56,8 @@
 
   @Override
   public Response<PluginInfo> apply(TopLevelResource resource, InstallPluginInput input)
-      throws BadRequestException, MethodNotAllowedException, IOException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
+      throws RestApiException, IOException {
+    loader.checkRemoteAdminEnabled();
     try {
       try (InputStream in = openStream(input)) {
         String pluginName = loader.installPluginFromStream(name, in);
@@ -104,7 +102,7 @@
 
     @Override
     public Response<PluginInfo> apply(PluginResource resource, InstallPluginInput input)
-        throws BadRequestException, MethodNotAllowedException, IOException {
+        throws RestApiException, IOException {
       return install.get().setName(resource.getName()).apply(TopLevelResource.INSTANCE, input);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index d972087..954ea29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
@@ -138,6 +139,12 @@
     return remoteAdmin;
   }
 
+  public void checkRemoteAdminEnabled() throws MethodNotAllowedException {
+    if (!remoteAdmin) {
+      throw new MethodNotAllowedException("remote plugin administration is disabled");
+    }
+  }
+
   public Plugin get(String name) {
     Plugin p = running.get(name);
     if (p != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
index 768aa86..9dbc956 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
@@ -17,8 +17,8 @@
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -67,11 +67,8 @@
   }
 
   @Override
-  public InstallPlugin create(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, MethodNotAllowedException {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw new MethodNotAllowedException("remote installation is disabled");
-    }
+  public InstallPlugin create(TopLevelResource parent, IdString id) throws RestApiException {
+    loader.checkRemoteAdminEnabled();
     return install.get().setName(id.get()).setCreated(true);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
index 7b464bb..1134f50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
@@ -17,10 +17,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.ReloadPlugin.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.PrintWriter;
@@ -29,7 +29,6 @@
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class ReloadPlugin implements RestModifyView<PluginResource, Input> {
-  public static class Input {}
 
   private final PluginLoader loader;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
index d43a066..a9e8fd3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
@@ -211,8 +211,8 @@
     DashboardInfo info = newDashboardInfo(refName, path);
     info.project = project;
     info.definingProject = definingProject.getName();
-    String query = config.getString("dashboard", null, "title");
-    info.title = replace(project, query == null ? info.path : query);
+    String title = config.getString("dashboard", null, "title");
+    info.title = replace(project, title == null ? info.path : title);
     info.description = replace(project, config.getString("dashboard", null, "description"));
     info.foreach = config.getString("dashboard", null, "foreach");
 
@@ -238,8 +238,8 @@
     return info;
   }
 
-  private static String replace(String project, String query) {
-    return query.replace("${project}", project);
+  private static String replace(String project, String input) {
+    return input == null ? input : input.replace("${project}", project);
   }
 
   private static String defaultOf(Project proj) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
index 8cd44d1..7c7d6af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
@@ -16,6 +16,7 @@
 
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -24,7 +25,6 @@
 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.DeleteBranch.Input;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -34,7 +34,6 @@
 
 @Singleton
 public class DeleteBranch implements RestModifyView<BranchResource, Input> {
-  public static class Input {}
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final DeleteRef.Factory deleteRefFactory;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
index a05fa2e..234f1d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -28,8 +29,7 @@
 import java.io.IOException;
 
 @Singleton
-public class DeleteTag implements RestModifyView<TagResource, DeleteTag.Input> {
-  public static class Input {}
+public class DeleteTag implements RestModifyView<TagResource, Input> {
 
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
index 0f71ac8..b68446f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PerRequestProjectControlCache.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.inject.Inject;
 import com.google.inject.servlet.RequestScoped;
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -48,7 +49,7 @@
     return ctl;
   }
 
-  public void evict(Project project) {
+  public void evict(Project project) throws IOException {
     projectCache.evict(project);
     controls.remove(project.getNameKey());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
index 65c7315..63052bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
@@ -45,17 +45,27 @@
    */
   ProjectState checkedGet(Project.NameKey projectName) throws IOException;
 
-  /** Invalidate the cached information about the given project. */
-  void evict(Project p);
+  /**
+   * Invalidate the cached information about the given project, and triggers reindexing for it
+   *
+   * @param p project that is being evicted
+   * @throws IOException thrown if the reindexing fails
+   */
+  void evict(Project p) throws IOException;
 
-  /** Invalidate the cached information about the given project. */
-  void evict(Project.NameKey p);
+  /**
+   * Invalidate the cached information about the given project, and triggers reindexing for it
+   *
+   * @param p the NameKey of the project that is being evicted
+   * @throws IOException thrown if the reindexing fails
+   */
+  void evict(Project.NameKey p) throws IOException;
 
   /**
    * Remove information about the given project from the cache. It will no longer be returned from
    * {@link #all()}.
    */
-  void remove(Project p);
+  void remove(Project p) throws IOException;
 
   /** @return sorted iteration of projects. */
   Iterable<Project.NameKey> all();
@@ -75,5 +85,5 @@
   Iterable<Project.NameKey> byName(String prefix);
 
   /** Notify the cache that a new project was constructed. */
-  void onCreateProject(Project.NameKey newProjectName);
+  void onCreateProject(Project.NameKey newProjectName) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 6ee143c..2b31ce3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -28,8 +28,10 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.index.project.ProjectIndexer;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
@@ -82,6 +84,7 @@
   private final LoadingCache<ListKey, SortedSet<Project.NameKey>> list;
   private final Lock listLock;
   private final ProjectCacheClock clock;
+  private final Provider<ProjectIndexer> indexer;
 
   @Inject
   ProjectCacheImpl(
@@ -89,13 +92,15 @@
       final AllUsersName allUsersName,
       @Named(CACHE_NAME) LoadingCache<String, ProjectState> byName,
       @Named(CACHE_LIST) LoadingCache<ListKey, SortedSet<Project.NameKey>> list,
-      ProjectCacheClock clock) {
+      ProjectCacheClock clock,
+      Provider<ProjectIndexer> indexer) {
     this.allProjectsName = allProjectsName;
     this.allUsersName = allUsersName;
     this.byName = byName;
     this.list = list;
     this.listLock = new ReentrantLock(true /* fair */);
     this.clock = clock;
+    this.indexer = indexer;
   }
 
   @Override
@@ -151,22 +156,20 @@
   }
 
   @Override
-  public void evict(Project p) {
-    if (p != null) {
-      byName.invalidate(p.getNameKey().get());
-    }
+  public void evict(Project p) throws IOException {
+    evict(p.getNameKey());
   }
 
-  /** Invalidate the cached information about the given project. */
   @Override
-  public void evict(Project.NameKey p) {
+  public void evict(Project.NameKey p) throws IOException {
     if (p != null) {
       byName.invalidate(p.get());
     }
+    indexer.get().index(p);
   }
 
   @Override
-  public void remove(Project p) {
+  public void remove(Project p) throws IOException {
     listLock.lock();
     try {
       SortedSet<Project.NameKey> n = Sets.newTreeSet(list.get(ListKey.ALL));
@@ -181,7 +184,7 @@
   }
 
   @Override
-  public void onCreateProject(Project.NameKey newProjectName) {
+  public void onCreateProject(Project.NameKey newProjectName) throws IOException {
     listLock.lock();
     try {
       SortedSet<Project.NameKey> n = Sets.newTreeSet(list.get(ListKey.ALL));
@@ -192,6 +195,7 @@
     } finally {
       listLock.unlock();
     }
+    indexer.get().index(newProjectName);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index 7a7418c..1166970 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -390,6 +390,10 @@
     }
   }
 
+  public boolean canRead() {
+    return !isHidden() && allRefsAreVisible(Collections.emptySet());
+  }
+
   ForProject asForProject() {
     return new ForProjectImpl();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectData.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectData.java
new file mode 100644
index 0000000..407529d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectData.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Project;
+
+public class ProjectData {
+  private final Project project;
+  private final ImmutableList<Project.NameKey> ancestors;
+
+  public ProjectData(Project project, Iterable<Project.NameKey> ancestors) {
+    this.project = project;
+    this.ancestors = ImmutableList.copyOf(ancestors);
+  }
+
+  public Project getProject() {
+    return project;
+  }
+
+  public ImmutableList<Project.NameKey> getAncestors() {
+    return ancestors;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 3015164..bd6386c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -553,6 +553,10 @@
     }
   }
 
+  public ProjectData toProjectData() {
+    return new ProjectData(getProject(), parents().transform(s -> s.getProject().getNameKey()));
+  }
+
   private String readFile(Path p) throws IOException {
     return Files.exists(p) ? new String(Files.readAllBytes(p), UTF_8) : null;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
index e0741f0..2a79470 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
@@ -14,11 +14,14 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NeedsParams;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -38,24 +41,31 @@
 
 @Singleton
 public class ProjectsCollection
-    implements RestCollection<TopLevelResource, ProjectResource>, AcceptsCreate<TopLevelResource> {
+    implements RestCollection<TopLevelResource, ProjectResource>,
+        AcceptsCreate<TopLevelResource>,
+        NeedsParams {
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
+  private final Provider<QueryProjects> queryProjects;
   private final ProjectControl.GenericFactory controlFactory;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CreateProject.Factory createProjectFactory;
 
+  private boolean hasQuery;
+
   @Inject
   ProjectsCollection(
       DynamicMap<RestView<ProjectResource>> views,
       Provider<ListProjects> list,
+      Provider<QueryProjects> queryProjects,
       ProjectControl.GenericFactory controlFactory,
       PermissionBackend permissionBackend,
       CreateProject.Factory factory,
       Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
+    this.queryProjects = queryProjects;
     this.controlFactory = controlFactory;
     this.permissionBackend = permissionBackend;
     this.user = user;
@@ -63,7 +73,16 @@
   }
 
   @Override
+  public void setParams(ListMultimap<String, String> params) throws BadRequestException {
+    // The --query option is defined in QueryProjects
+    this.hasQuery = params.containsKey("query");
+  }
+
+  @Override
   public RestView<TopLevelResource> list() {
+    if (hasQuery) {
+      return queryProjects.get();
+    }
     return list.get().setFormat(OutputFormat.JSON);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/QueryProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/QueryProjects.java
new file mode 100644
index 0000000..998bdb2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/QueryProjects.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
+import com.google.gerrit.server.index.project.ProjectIndex;
+import com.google.gerrit.server.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.query.project.ProjectQueryBuilder;
+import com.google.gerrit.server.query.project.ProjectQueryProcessor;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+
+public class QueryProjects implements RestReadView<TopLevelResource> {
+  private final ProjectIndexCollection indexes;
+  private final ProjectQueryBuilder queryBuilder;
+  private final ProjectQueryProcessor queryProcessor;
+  private final ProjectJson json;
+
+  private String query;
+  private int limit;
+  private int start;
+
+  @Option(
+    name = "--query",
+    aliases = {"-q"},
+    usage = "project query"
+  )
+  public void setQuery(String query) {
+    this.query = query;
+  }
+
+  @Option(
+    name = "--limit",
+    aliases = {"-n"},
+    metaVar = "CNT",
+    usage = "maximum number of projects to list"
+  )
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+    name = "--start",
+    aliases = {"-S"},
+    metaVar = "CNT",
+    usage = "number of projects to skip"
+  )
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Inject
+  protected QueryProjects(
+      ProjectIndexCollection indexes,
+      ProjectQueryBuilder queryBuilder,
+      ProjectQueryProcessor queryProcessor,
+      ProjectJson json) {
+    this.indexes = indexes;
+    this.queryBuilder = queryBuilder;
+    this.queryProcessor = queryProcessor;
+    this.json = json;
+  }
+
+  @Override
+  public List<ProjectInfo> apply(TopLevelResource resource)
+      throws BadRequestException, MethodNotAllowedException, OrmException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new BadRequestException("missing query field");
+    }
+
+    ProjectIndex searchIndex = indexes.getSearchIndex();
+    if (searchIndex == null) {
+      throw new MethodNotAllowedException("no project index");
+    }
+
+    if (start != 0) {
+      queryProcessor.setStart(start);
+    }
+
+    if (limit != 0) {
+      queryProcessor.setUserProvidedLimit(limit);
+    }
+
+    try {
+      QueryResult<ProjectData> result = queryProcessor.query(queryBuilder.parse(query));
+      List<ProjectData> pds = result.entities();
+
+      ArrayList<ProjectInfo> projectInfos = Lists.newArrayListWithCapacity(pds.size());
+      for (ProjectData pd : pds) {
+        projectInfos.add(json.format(pd.getProject()));
+      }
+      return projectInfos;
+    } catch (QueryParseException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 1ae579e..1f28dbd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -391,23 +392,23 @@
       return asUser(userFactory.create(otherId));
     }
 
-    IdentifiedUser getIdentifiedUser() throws QueryParseException {
+    IdentifiedUser getIdentifiedUser() throws QueryRequiresAuthException {
       try {
         CurrentUser u = getUser();
         if (u.isIdentifiedUser()) {
           return u.asIdentifiedUser();
         }
-        throw new QueryParseException(NotSignedInException.MESSAGE);
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE);
       } catch (ProvisionException e) {
-        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
       }
     }
 
-    CurrentUser getUser() throws QueryParseException {
+    CurrentUser getUser() throws QueryRequiresAuthException {
       try {
         return self.get();
       } catch (ProvisionException e) {
-        throw new QueryParseException(NotSignedInException.MESSAGE, e);
+        throw new QueryRequiresAuthException(NotSignedInException.MESSAGE, e);
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
index 65de137..fadc853 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gwtorm.server.OrmException;
@@ -32,11 +33,13 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
 import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class QueryChanges implements RestReadView<TopLevelResource> {
+  private static final Logger log = LoggerFactory.getLogger(QueryChanges.class);
+
   private final ChangeJson.Factory json;
   private final ChangeQueryBuilder qb;
   private final ChangeQueryProcessor imp;
@@ -106,15 +109,10 @@
     List<List<ChangeInfo>> out;
     try {
       out = query();
+    } catch (QueryRequiresAuthException e) {
+      throw new AuthException("Must be signed-in to use this operator");
     } catch (QueryParseException e) {
-      // This is a hack to detect an operator that requires authentication.
-      Pattern p =
-          Pattern.compile("^Error in operator (.*:self|is:watched|is:owner|is:reviewer|has:.*)$");
-      Matcher m = p.matcher(e.getMessage());
-      if (m.matches()) {
-        String op = m.group(1);
-        throw new AuthException("Must be signed-in to use " + op);
-      }
+      log.debug("Reject change query with 400 Bad Request: " + queries, e);
       throw new BadRequestException(e.getMessage(), e);
     }
     return out.size() == 1 ? out.get(0) : out;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
new file mode 100644
index 0000000..20032ce
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectIsVisibleToPredicate.java
@@ -0,0 +1,48 @@
+// 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.query.project;
+
+import com.google.gerrit.index.query.IsVisibleToPredicate;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.gerrit.server.query.account.AccountQueryBuilder;
+import com.google.gwtorm.server.OrmException;
+
+public class ProjectIsVisibleToPredicate extends IsVisibleToPredicate<ProjectData> {
+  protected final PermissionBackend permissionBackend;
+  protected final CurrentUser user;
+
+  public ProjectIsVisibleToPredicate(PermissionBackend permissionBackend, CurrentUser user) {
+    super(AccountQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+  }
+
+  @Override
+  public boolean match(ProjectData pd) throws OrmException {
+    return permissionBackend
+        .user(user)
+        .project(pd.getProject().getNameKey())
+        .testOrFalse(ProjectPermission.READ);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectPredicates.java
new file mode 100644
index 0000000..379c564
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -0,0 +1,45 @@
+// 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.query.project;
+
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.index.project.ProjectField;
+import com.google.gerrit.server.project.ProjectData;
+import java.util.Locale;
+
+public class ProjectPredicates {
+  public static Predicate<ProjectData> name(Project.NameKey nameKey) {
+    return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+  }
+
+  public static Predicate<ProjectData> inname(String name) {
+    return new ProjectPredicate(ProjectField.NAME_PART, name.toLowerCase(Locale.US));
+  }
+
+  public static Predicate<ProjectData> description(String description) {
+    return new ProjectPredicate(ProjectField.DESCRIPTION, description);
+  }
+
+  static class ProjectPredicate extends IndexPredicate<ProjectData> {
+    ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
+      super(def, value);
+    }
+  }
+
+  private ProjectPredicates() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
new file mode 100644
index 0000000..e9e9c0f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -0,0 +1,83 @@
+// 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.query.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+import java.util.List;
+
+/** Parses a query string meant to be applied to project objects. */
+public class ProjectQueryBuilder extends QueryBuilder<ProjectData> {
+  public static final String FIELD_LIMIT = "limit";
+
+  private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(ProjectQueryBuilder.class);
+
+  @Inject
+  ProjectQueryBuilder() {
+    super(mydef);
+  }
+
+  @Operator
+  public Predicate<ProjectData> name(String name) {
+    return ProjectPredicates.name(new Project.NameKey(name));
+  }
+
+  @Operator
+  public Predicate<ProjectData> inname(String namePart) {
+    if (namePart.isEmpty()) {
+      return name(namePart);
+    }
+    return ProjectPredicates.inname(namePart);
+  }
+
+  @Operator
+  public Predicate<ProjectData> description(String description) throws QueryParseException {
+    if (Strings.isNullOrEmpty(description)) {
+      throw error("description operator requires a value");
+    }
+
+    return ProjectPredicates.description(description);
+  }
+
+  @Override
+  protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
+    // Adapt the capacity of this list when adding more default predicates.
+    List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
+    preds.add(name(query));
+    preds.add(inname(query));
+    if (!Strings.isNullOrEmpty(query)) {
+      preds.add(description(query));
+    }
+    return Predicate.or(preds);
+  }
+
+  @Operator
+  public Predicate<ProjectData> limit(String query) throws QueryParseException {
+    Integer limit = Ints.tryParse(query);
+    if (limit == null) {
+      throw error("Invalid limit: " + query);
+    }
+    return new LimitPredicate<>(FIELD_LIMIT, limit);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
new file mode 100644
index 0000000..1e181e5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/project/ProjectQueryProcessor.java
@@ -0,0 +1,79 @@
+// 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.query.project;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.query.project.ProjectQueryBuilder.FIELD_LIMIT;
+
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.AndSource;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryProcessor;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.index.project.ProjectIndexCollection;
+import com.google.gerrit.server.index.project.ProjectIndexRewriter;
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectData;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/**
+ * Query processor for the project index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class ProjectQueryProcessor extends QueryProcessor<ProjectData> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> userProvider;
+
+  static {
+    // It is assumed that basic rewrites do not touch visibleto predicates.
+    checkState(
+        !ProjectIsVisibleToPredicate.class.isAssignableFrom(IndexPredicate.class),
+        "ProjectQueryProcessor assumes visibleto is not used by the index rewriter.");
+  }
+
+  @Inject
+  protected ProjectQueryProcessor(
+      Provider<CurrentUser> userProvider,
+      AccountLimits.Factory limitsFactory,
+      MetricMaker metricMaker,
+      IndexConfig indexConfig,
+      ProjectIndexCollection indexes,
+      ProjectIndexRewriter rewriter,
+      PermissionBackend permissionBackend) {
+    super(
+        metricMaker,
+        ProjectSchemaDefinitions.INSTANCE,
+        indexConfig,
+        indexes,
+        rewriter,
+        FIELD_LIMIT,
+        () -> limitsFactory.create(userProvider.get()).getQueryLimit());
+    this.permissionBackend = permissionBackend;
+    this.userProvider = userProvider;
+  }
+
+  @Override
+  protected Predicate<ProjectData> enforceVisibility(Predicate<ProjectData> pred) {
+    return new AndSource<>(
+        pred, new ProjectIsVisibleToPredicate(permissionBackend, userProvider.get()), start);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
index eaa97e4d5..d43b887 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdReader;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
@@ -93,7 +94,18 @@
           ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
         }
 
-        ExternalIdsUpdate.commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, serverIdent, serverIdent);
+        ExternalIdsUpdate.commit(
+            allUsersName,
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            COMMIT_MSG,
+            serverIdent,
+            serverIdent,
+            null,
+            GitReferenceUpdated.DISABLED);
       }
     } catch (IOException | ConfigInvalidException e) {
       throw new OrmException("Failed to migrate external IDs to NoteDb", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
index 421e28d..47751cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdReader;
 import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -84,7 +85,18 @@
         }
       }
       if (dirty) {
-        ExternalIdsUpdate.commit(repo, rw, ins, rev, noteMap, COMMIT_MSG, serverUser, serverUser);
+        ExternalIdsUpdate.commit(
+            allUsersName,
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            COMMIT_MSG,
+            serverUser,
+            serverUser,
+            null,
+            GitReferenceUpdated.DISABLED);
       }
     } catch (IOException e) {
       throw new OrmException("Failed to update external IDs", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java
index 64f60e3..2bb2a33 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gwtorm.server.OrmException;
@@ -42,7 +43,13 @@
     @SuppressWarnings("deprecation")
     RepoSequence.Seed accountSeed = () -> db.nextAccountId();
     RepoSequence accountSeq =
-        new RepoSequence(repoManager, allUsersName, Sequences.NAME_ACCOUNTS, accountSeed, 1);
+        new RepoSequence(
+            repoManager,
+            GitReferenceUpdated.DISABLED,
+            allUsersName,
+            Sequences.NAME_ACCOUNTS,
+            accountSeed,
+            1);
 
     // consume one account ID to ensure that the account sequence is initialized in NoteDb
     accountSeq.next();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java
index 407492d..febe80e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java
@@ -16,6 +16,7 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.primitives.Ints;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.StarredChangesUtil;
@@ -27,6 +28,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -54,19 +58,53 @@
     try (Repository git = repoManager.openRepository(allUsersName);
         RevWalk rw = new RevWalk(git)) {
       BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+      bru.setAllowNonFastForwards(true);
+
       for (Ref ref : git.getRefDatabase().getRefs(RefNames.REFS_STARRED_CHANGES).values()) {
         StarRef starRef = StarredChangesUtil.readLabels(git, ref.getName());
-        if (starRef.labels().contains(MUTE_LABEL)) {
-          ObjectId id =
-              StarredChangesUtil.writeLabels(
-                  git,
-                  starRef
-                      .labels()
-                      .stream()
-                      .map(l -> l.equals(MUTE_LABEL) ? StarredChangesUtil.REVIEWED_LABEL : l)
-                      .collect(toList()));
-          bru.addCommand(new ReceiveCommand(ObjectId.zeroId(), id, ref.getName()));
+
+        Set<Integer> mutedPatchSets =
+            StarredChangesUtil.getStarredPatchSets(starRef.labels(), MUTE_LABEL);
+        if (mutedPatchSets.isEmpty()) {
+          continue;
         }
+
+        Set<Integer> reviewedPatchSets =
+            StarredChangesUtil.getStarredPatchSets(
+                starRef.labels(), StarredChangesUtil.REVIEWED_LABEL);
+        Set<Integer> unreviewedPatchSets =
+            StarredChangesUtil.getStarredPatchSets(
+                starRef.labels(), StarredChangesUtil.UNREVIEWED_LABEL);
+
+        List<String> newLabels =
+            starRef
+                .labels()
+                .stream()
+                .map(
+                    l -> {
+                      if (l.startsWith(MUTE_LABEL)) {
+                        Integer mutedPatchSet = Ints.tryParse(l.substring(MUTE_LABEL.length() + 1));
+                        if (mutedPatchSet == null) {
+                          // unexpected format of mute label, must be a label that was manually
+                          // set, just leave it alone
+                          return l;
+                        }
+                        if (!reviewedPatchSets.contains(mutedPatchSet)
+                            && !unreviewedPatchSets.contains(mutedPatchSet)) {
+                          // convert mute label to reviewed label
+                          return StarredChangesUtil.REVIEWED_LABEL + "/" + mutedPatchSet;
+                        }
+                        // else patch set is muted but has either reviewed or unreviewed label
+                        // -> just drop the mute label
+                        return null;
+                      }
+                      return l;
+                    })
+                .filter(Objects::nonNull)
+                .collect(toList());
+
+        ObjectId id = StarredChangesUtil.writeLabels(git, newLabels);
+        bru.addCommand(new ReceiveCommand(ref.getTarget().getObjectId(), id, ref.getName()));
       }
       bru.execute(rw, new TextProgressMonitor());
     } catch (IOException | IllegalLabelException ex) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
index 2088409..e757d77 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
@@ -27,21 +27,27 @@
   private static final Logger log = LoggerFactory.getLogger(MagicBranch.class);
 
   public static final String NEW_CHANGE = "refs/for/";
-  // TODO: remove after 'repo' supports private/wip changes.
+  // TODO(xchangcheng): remove after 'repo' supports private/wip changes.
   public static final String NEW_DRAFT_CHANGE = "refs/drafts/";
+  // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
+  public static final String NEW_PUBLISH_CHANGE = "refs/publish/";
 
   /** Extracts the destination from a ref name */
   public static String getDestBranchName(String refName) {
     String magicBranch = NEW_CHANGE;
     if (refName.startsWith(NEW_DRAFT_CHANGE)) {
       magicBranch = NEW_DRAFT_CHANGE;
+    } else if (refName.startsWith(NEW_PUBLISH_CHANGE)) {
+      magicBranch = NEW_PUBLISH_CHANGE;
     }
     return refName.substring(magicBranch.length());
   }
 
   /** Checks if the supplied ref name is a magic branch */
   public static boolean isMagicBranch(String refName) {
-    return refName.startsWith(NEW_DRAFT_CHANGE) || refName.startsWith(NEW_CHANGE);
+    return refName.startsWith(NEW_DRAFT_CHANGE)
+        || refName.startsWith(NEW_PUBLISH_CHANGE)
+        || refName.startsWith(NEW_CHANGE);
   }
 
   /** Returns the ref name prefix for a magic branch, {@code null} if the branch is not magic */
@@ -49,6 +55,9 @@
     if (refName.startsWith(NEW_DRAFT_CHANGE)) {
       return NEW_DRAFT_CHANGE;
     }
+    if (refName.startsWith(NEW_PUBLISH_CHANGE)) {
+      return NEW_PUBLISH_CHANGE;
+    }
     if (refName.startsWith(NEW_CHANGE)) {
       return NEW_CHANGE;
     }
@@ -72,6 +81,11 @@
     if (result != Capable.OK) {
       return result;
     }
+    result = checkMagicBranchRef(NEW_PUBLISH_CHANGE, repo, project);
+    if (result != Capable.OK) {
+      return result;
+    }
+
     return Capable.OK;
   }
 
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
index fb8ff78..75d940f 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AbandonedHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .AbandonedHtml kind="html"}
+{template .AbandonedHtml}
   <p>
     {$fromName} <strong>abandoned</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
index 21161ea..712abc7 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/AddKeyHtml.soy
@@ -19,7 +19,7 @@
 /**
  * @param email
  */
-{template .AddKeyHtml kind="html"}
+{template .AddKeyHtml}
   <p>
     One or more new {$email.keyType} keys have been added to Gerrit Code Review
     at {$email.gerritHost}:
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index dea6724..99263e8 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -20,7 +20,7 @@
  * @param change
  * @param email
  */
-{template .ChangeFooterHtml kind="html"}
+{template .ChangeFooterHtml}
   {if $email.changeUrl or $email.settingsUrl}
     <p>
       {if $email.changeUrl}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
index c54f926..033c1b1 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentFooterHtml.soy
@@ -16,5 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .CommentFooterHtml kind="html"}
+{template .CommentFooterHtml}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 9b96d69..6917736 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param patchSetCommentBlocks
  */
-{template .CommentHtml kind="html"}
+{template .CommentHtml}
   {let $commentHeaderStyle kind="css"}
     margin-bottom: 4px;
   {/let}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 74e5ee5..f73e387 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteReviewerHtml kind="html"}
+{template .DeleteReviewerHtml}
   <p>
     {$fromName}{sp}
     <strong>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
index 06f5456..cb8162d 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/DeleteVoteHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .DeleteVoteHtml kind="html"}
+{template .DeleteVoteHtml}
   <p>
     {$fromName} <strong>removed a vote</strong> from this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
index d9f13ce..22929d1 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy
@@ -19,7 +19,7 @@
 /**
  * @param footers
  */
-{template .FooterHtml kind="html"}
+{template .FooterHtml}
   {\n}
   {\n}
   {foreach $footer in $footers}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
index 85b56ec..4710d8c 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/HeaderHtml.soy
@@ -16,5 +16,5 @@
 
 {namespace com.google.gerrit.server.mail.template}
 
-{template .HeaderHtml kind="html"}
+{template .HeaderHtml}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
index 08d37cc..b11c5e5 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -21,7 +21,7 @@
  * @param email
  * @param fromName
  */
-{template .MergedHtml kind="html"}
+{template .MergedHtml}
   <p>
     {$fromName} <strong>merged</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 676f019..16b0df4 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .NewChangeHtml kind="html"}
+{template .NewChangeHtml}
   <p>
     {if $email.reviewerNames}
       {$fromName} would like{sp}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
index c1ac5b6..5840223 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -24,7 +24,7 @@
  * Private template to generate "View Change" buttons.
  * @param email
  */
-{template .ViewChangeButton kind="html"}
+{template .ViewChangeButton}
   <a href="{$email.changeUrl}">View Change</a>
 {/template}
 
@@ -32,7 +32,7 @@
  * Private template to render PRE block with consistent font-sizing.
  * @param content
  */
-{template .Pre kind="html"}
+{template .Pre}
   {let $preStyle kind="css"}
     font-family: monospace,monospace; // Use this to avoid browsers scaling down
                                       // monospace text.
@@ -56,7 +56,7 @@
  *
  * @param content
  */
-{template .WikiFormat kind="html"}
+{template .WikiFormat}
   {let $blockquoteStyle kind="css"}
     border-left: 1px solid #aaa;
     margin: 10px 0;
@@ -90,7 +90,7 @@
 /**
  * @param diffLines
  */
-{template .UnifiedDiff kind="html"}
+{template .UnifiedDiff}
   {let $addStyle kind="css"}
     color: hsl(120, 100%, 40%);
   {/let}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
index 221a4e6..e618bef 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ReplacePatchSetHtml.soy
@@ -24,7 +24,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .ReplacePatchSetHtml kind="html"}
+{template .ReplacePatchSetHtml}
   <p>
     {$fromName} <strong>uploaded patch set #{$patchSet.patchSetId}</strong>{sp}
     to{sp}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
index fdc68b0..bb856ac 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RestoredHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .RestoredHtml kind="html"}
+{template .RestoredHtml}
   <p>
     {$fromName} <strong>restored</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
index 479eae1..63ad6f0 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/RevertedHtml.soy
@@ -20,7 +20,7 @@
  * @param email
  * @param fromName
  */
-{template .RevertedHtml kind="html"}
+{template .RevertedHtml}
   <p>
     {$fromName} <strong>reverted</strong> this change.
   </p>
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index d057ba3..dbd3fae 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -23,7 +23,7 @@
  * @param patchSet
  * @param projectName
  */
-{template .SetAssigneeHtml kind="html"}
+{template .SetAssigneeHtml}
   <p>
     {$fromName} has <strong>assigned</strong> a change to{sp}
     {$email.assigneeName}.{sp}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
index 66ccdad..76be4569 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -25,6 +25,7 @@
 import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
@@ -264,7 +265,14 @@
       Runnable afterReadRef,
       Retryer<RefUpdate.Result> retryer) {
     return new RepoSequence(
-        repoManager, project, name, () -> start, batchSize, afterReadRef, retryer);
+        repoManager,
+        GitReferenceUpdated.DISABLED,
+        project,
+        name,
+        () -> start,
+        batchSize,
+        afterReadRef,
+        retryer);
   }
 
   private ObjectId writeBlob(String sequenceName, String value) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
new file mode 100644
index 0000000..8804b96
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -0,0 +1,372 @@
+// 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.query.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.api.projects.Projects.QueryRequest;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.GerritServerTests;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+
+@Ignore
+public abstract class AbstractQueryProjectsTest extends GerritServerTests {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setInt("index", null, "maxPages", 10);
+    return cfg;
+  }
+
+  @Inject protected Accounts accounts;
+
+  @Inject protected AccountsUpdate.Server accountsUpdate;
+
+  @Inject protected AccountCache accountCache;
+
+  @Inject protected AccountManager accountManager;
+
+  @Inject protected GerritApi gApi;
+
+  @Inject protected IdentifiedUser.GenericFactory userFactory;
+
+  @Inject private Provider<AnonymousUser> anonymousUser;
+
+  @Inject protected InMemoryDatabase schemaFactory;
+
+  @Inject protected SchemaCreator schemaCreator;
+
+  @Inject protected ThreadLocalRequestContext requestContext;
+
+  @Inject protected OneOffRequestContext oneOffRequestContext;
+
+  @Inject protected InternalAccountQuery internalAccountQuery;
+
+  @Inject protected AllProjectsName allProjects;
+
+  protected LifecycleManager lifecycle;
+  protected Injector injector;
+  protected ReviewDb db;
+  protected AccountInfo currentUserInfo;
+  protected CurrentUser user;
+
+  protected abstract Injector createInjector();
+
+  @Before
+  public void setUpInjector() throws Exception {
+    lifecycle = new LifecycleManager();
+    injector = createInjector();
+    lifecycle.add(injector);
+    injector.injectMembers(this);
+    lifecycle.start();
+    setUpDatabase();
+  }
+
+  protected void setUpDatabase() throws Exception {
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Account.Id userId = createAccount("user", "User", "user@example.com", true);
+    user = userFactory.create(userId);
+    requestContext.setContext(newRequestContext(userId));
+    currentUserInfo = gApi.accounts().id(userId.get()).get();
+  }
+
+  protected RequestContext newRequestContext(Account.Id requestUserId) {
+    final CurrentUser requestUser = userFactory.create(requestUserId);
+    return new RequestContext() {
+      @Override
+      public CurrentUser getUser() {
+        return requestUser;
+      }
+
+      @Override
+      public Provider<ReviewDb> getReviewDbProvider() {
+        return Providers.of(db);
+      }
+    };
+  }
+
+  protected void setAnonymous() {
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return anonymousUser.get();
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDownInjector() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void byName() throws Exception {
+    assertQuery("name:project");
+    assertQuery("name:non-existing");
+
+    ProjectInfo project = createProject(name("project"));
+
+    assertQuery("name:" + project.name, project);
+
+    // only exact match
+    ProjectInfo projectWithHyphen = createProject(name("project-with-hyphen"));
+    createProject(name("project-no-match-with-hyphen"));
+    assertQuery("name:" + projectWithHyphen.name, projectWithHyphen);
+  }
+
+  @Test
+  public void byInname() throws Exception {
+    String namePart = getSanitizedMethodName();
+    namePart = CharMatcher.is('_').removeFrom(namePart);
+
+    ProjectInfo project1 = createProject(name("project-" + namePart));
+    ProjectInfo project2 = createProject(name("project-" + namePart + "-2"));
+    ProjectInfo project3 = createProject(name("project-" + namePart + "3"));
+
+    assertQuery("inname:" + namePart, project1, project2, project3);
+    assertQuery("inname:" + namePart.toUpperCase(Locale.US), project1, project2, project3);
+    assertQuery("inname:" + namePart.toLowerCase(Locale.US), project1, project2, project3);
+  }
+
+  @Test
+  public void byDescription() throws Exception {
+    ProjectInfo project1 =
+        createProjectWithDescription(name("project1"), "This is a test project.");
+    ProjectInfo project2 = createProjectWithDescription(name("project2"), "ANOTHER TEST PROJECT.");
+    createProjectWithDescription(name("project3"), "Maintainers of project foo.");
+    assertQuery("description:test", project1, project2);
+
+    assertQuery("description:non-existing");
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("description operator requires a value");
+    assertQuery("description:\"\"");
+  }
+
+  @Test
+  public void byDefaultField() throws Exception {
+    ProjectInfo project1 = createProject(name("foo-project"));
+    ProjectInfo project2 = createProject(name("project2"));
+    ProjectInfo project3 =
+        createProjectWithDescription(
+            name("project3"),
+            "decription that contains foo and the UUID of project2: " + project2.id);
+
+    assertQuery("non-existing");
+    assertQuery("foo", project1, project3);
+    assertQuery(project2.id, project2, project3);
+  }
+
+  @Test
+  public void withLimit() throws Exception {
+    ProjectInfo project1 = createProject(name("project1"));
+    ProjectInfo project2 = createProject(name("project2"));
+    ProjectInfo project3 = createProject(name("project3"));
+
+    String query =
+        "name:" + project1.name + " OR name:" + project2.name + " OR name:" + project3.name;
+    List<ProjectInfo> result = assertQuery(query, project1, project2, project3);
+
+    result = assertQuery(newQuery(query).withLimit(2), result.subList(0, 2));
+  }
+
+  @Test
+  public void withStart() throws Exception {
+    ProjectInfo project1 = createProject(name("project1"));
+    ProjectInfo project2 = createProject(name("project2"));
+    ProjectInfo project3 = createProject(name("project3"));
+
+    String query =
+        "name:" + project1.name + " OR name:" + project2.name + " OR name:" + project3.name;
+    List<ProjectInfo> result = assertQuery(query, project1, project2, project3);
+
+    assertQuery(newQuery(query).withStart(1), result.subList(1, 3));
+  }
+
+  @Test
+  public void asAnonymous() throws Exception {
+    ProjectInfo project = createProject(name("project"));
+
+    setAnonymous();
+    assertQuery("name:" + project.name);
+  }
+
+  private Account.Id createAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      accountsUpdate
+          .create()
+          .update(
+              id,
+              a -> {
+                a.setFullName(fullName);
+                a.setPreferredEmail(email);
+                a.setActive(active);
+              });
+      return id;
+    }
+  }
+
+  protected ProjectInfo createProject(String name) throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name;
+    return gApi.projects().create(in).get();
+  }
+
+  protected ProjectInfo createProjectWithDescription(String name, String description)
+      throws Exception {
+    ProjectInput in = new ProjectInput();
+    in.name = name;
+    in.description = description;
+    return gApi.projects().create(in).get();
+  }
+
+  protected ProjectInfo getProject(Project.NameKey nameKey) throws Exception {
+    return gApi.projects().name(nameKey.get()).get();
+  }
+
+  protected List<ProjectInfo> assertQuery(Object query, ProjectInfo... projects) throws Exception {
+    return assertQuery(newQuery(query), projects);
+  }
+
+  protected List<ProjectInfo> assertQuery(QueryRequest query, ProjectInfo... projects)
+      throws Exception {
+    return assertQuery(query, Arrays.asList(projects));
+  }
+
+  protected List<ProjectInfo> assertQuery(QueryRequest query, List<ProjectInfo> projects)
+      throws Exception {
+    List<ProjectInfo> result = query.get();
+    Iterable<String> names = names(result);
+    assertThat(names)
+        .named(format(query, result, projects))
+        .containsExactlyElementsIn(names(projects));
+    return result;
+  }
+
+  protected QueryRequest newQuery(Object query) {
+    return gApi.projects().query(query.toString());
+  }
+
+  protected String format(
+      QueryRequest query, List<ProjectInfo> actualProjects, List<ProjectInfo> expectedProjects) {
+    StringBuilder b = new StringBuilder();
+    b.append("query '").append(query.getQuery()).append("' with expected projects ");
+    b.append(format(expectedProjects));
+    b.append(" and result ");
+    b.append(format(actualProjects));
+    return b.toString();
+  }
+
+  protected String format(Iterable<ProjectInfo> projects) {
+    StringBuilder b = new StringBuilder();
+    b.append("[");
+    Iterator<ProjectInfo> it = projects.iterator();
+    while (it.hasNext()) {
+      ProjectInfo p = it.next();
+      b.append("{")
+          .append(p.id)
+          .append(", ")
+          .append("name=")
+          .append(p.name)
+          .append(", ")
+          .append("parent=")
+          .append(p.parent)
+          .append(", ")
+          .append("description=")
+          .append(p.description)
+          .append("}");
+      if (it.hasNext()) {
+        b.append(", ");
+      }
+    }
+    b.append("]");
+    return b.toString();
+  }
+
+  protected static Iterable<String> names(ProjectInfo... projects) {
+    return names(Arrays.asList(projects));
+  }
+
+  protected static Iterable<String> names(List<ProjectInfo> projects) {
+    return projects.stream().map(p -> p.name).collect(toList());
+  }
+
+  protected String name(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    return name + "_" + getSanitizedMethodName();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
new file mode 100644
index 0000000..4a09d87
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/project/LuceneQueryProjectsTest.java
@@ -0,0 +1,44 @@
+// 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.query.project;
+
+import com.google.gerrit.server.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class LuceneQueryProjectsTest extends AbstractQueryProjectsTest {
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(
+            com.google.gerrit.server.index.project.ProjectSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        ProjectSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config luceneConfig = new Config(config);
+    InMemoryModule.setDefaults(luceneConfig);
+    return Guice.createInjector(new InMemoryModule(luceneConfig, notesMigration));
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index 0d7fa24..6649fcb 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -174,7 +174,13 @@
         err.append("error: ").append(msg).append("\n");
       }
 
-      projectCache.evict(nameKey);
+      try {
+        projectCache.evict(nameKey);
+      } catch (IOException e) {
+        final String msg = "Cannot reindex project: " + name;
+        log.error(msg, e);
+        err.append("error: ").append(msg).append("\n");
+      }
     }
 
     if (err.length() > 0) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index 821257c..bb33dea 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Index;
@@ -55,7 +56,7 @@
     boolean ok = true;
     for (ChangeResource rsrc : changes.values()) {
       try {
-        index.apply(rsrc, new Index.Input());
+        index.apply(rsrc, new Input());
       } catch (Exception e) {
         ok = false;
         writeError(
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
new file mode 100644
index 0000000..7e32615
--- /dev/null
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
@@ -0,0 +1,36 @@
+// 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.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.plugins.PluginLoader;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+public abstract class PluginAdminSshCommand extends SshCommand {
+  @Inject protected PluginLoader loader;
+
+  abstract void doRun() throws UnloggedFailure;
+
+  @Override
+  protected final void run() throws UnloggedFailure {
+    if (!loader.isRemoteAdminEnabled()) {
+      throw die("remote plugin administration is disabled");
+    }
+    doRun();
+  }
+}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
index d7c8f3a..baaf715 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginEnableCommand.java
@@ -17,29 +17,18 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "enable", description = "Enable plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginEnableCommand extends SshCommand {
+final class PluginEnableCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin(s) to enable")
   List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
       try {
         loader.enablePlugins(Sets.newHashSet(names));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
index 820052c..337eadb 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginInstallCommand.java
@@ -17,13 +17,8 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -33,9 +28,8 @@
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "install", description = "Install/Add a plugin", runsAt = MASTER_OR_SLAVE)
-final class PluginInstallCommand extends SshCommand {
+final class PluginInstallCommand extends PluginAdminSshCommand {
   @Option(
     name = "--name",
     aliases = {"-n"},
@@ -51,14 +45,9 @@
   @Argument(index = 0, metaVar = "-|URL", usage = "JAR to load")
   private String source;
 
-  @Inject private PluginLoader loader;
-
   @SuppressWarnings("resource")
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote installation is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (Strings.isNullOrEmpty(source)) {
       throw die("Argument \"-|URL\" is required");
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
index 0f2c912..86a74d1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginReloadCommand.java
@@ -16,30 +16,19 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.server.plugins.InvalidPluginException;
 import com.google.gerrit.server.plugins.PluginInstallException;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "reload", description = "Reload/Restart plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginReloadCommand extends SshCommand {
+final class PluginReloadCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", usage = "plugins to reload/restart")
   private List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names == null || names.isEmpty()) {
       loader.rescan();
     } else {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
index 8a38739..0119349b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -17,28 +17,17 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.plugins.PluginLoader;
 import com.google.gerrit.sshd.CommandMetaData;
-import com.google.gerrit.sshd.SshCommand;
-import com.google.inject.Inject;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
 
-@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @CommandMetaData(name = "remove", description = "Disable plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginRemoveCommand extends SshCommand {
+final class PluginRemoveCommand extends PluginAdminSshCommand {
   @Argument(index = 0, metaVar = "NAME", required = true, usage = "plugin to remove")
   List<String> names;
 
-  @Inject private PluginLoader loader;
-
   @Override
-  protected void run() throws UnloggedFailure {
-    if (!loader.isRemoteAdminEnabled()) {
-      throw die("remote plugin administration is disabled");
-    }
+  protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
       loader.disablePlugins(Sets.newHashSet(names));
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index 6ec3a28..74dcc12 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -48,7 +49,7 @@
   protected void run() throws Failure {
     try {
       GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
-      PutName.Input input = new PutName.Input();
+      NameInput input = new NameInput();
       input.name = newGroupName;
       putName.apply(rsrc, input);
     } catch (RestApiException | OrmException | IOException e) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 033b4c6..656d377 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -23,7 +23,11 @@
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.common.EmailInfo;
+import com.google.gerrit.extensions.common.HttpPasswordInput;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.common.NameInput;
 import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.common.SshKeyInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -193,13 +197,13 @@
       }
 
       if (fullName != null) {
-        PutName.Input in = new PutName.Input();
+        NameInput in = new NameInput();
         in.name = fullName;
         putName.apply(rsrc, in);
       }
 
       if (httpPassword != null || clearHttpPassword) {
-        PutHttpPassword.Input in = new PutHttpPassword.Input();
+        HttpPasswordInput in = new HttpPasswordInput();
         in.httpPassword = httpPassword;
         putHttpPassword.apply(rsrc, in);
       }
@@ -232,7 +236,7 @@
       throws RestApiException, OrmException, IOException, ConfigInvalidException,
           PermissionBackendException {
     for (String sshKey : sshKeys) {
-      AddSshKey.Input in = new AddSshKey.Input();
+      SshKeyInput in = new SshKeyInput();
       in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
       addSshKey.apply(rsrc, in);
     }
@@ -284,10 +288,10 @@
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
-        deleteEmail.apply(new AccountResource.Email(user, e.email), new DeleteEmail.Input());
+        deleteEmail.apply(new AccountResource.Email(user, e.email), new Input());
       }
     } else {
-      deleteEmail.apply(new AccountResource.Email(user, email), new DeleteEmail.Input());
+      deleteEmail.apply(new AccountResource.Email(user, email), new Input());
     }
   }
 
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index 63761f2..1d964b8 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,12 +1,12 @@
 load("//tools/bzl:maven_jar.bzl", "GERRIT", "MAVEN_LOCAL", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.8.0.201706111038-r.71-g45da0fc6f"
+_JGIT_VERS = "4.9.0.201710071750-r"
 
-_DOC_VERS = "4.8.0.201706111038-r" # Set to _JGIT_VERS unless using a snapshot
+_DOC_VERS = _JGIT_VERS # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-_JGIT_REPO = GERRIT # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = MAVEN_CENTRAL # Leave here even if set to MAVEN_CENTRAL.
 
 # set this to use a local version.
 # "/home/<user>/projects/jgit"
@@ -26,28 +26,28 @@
         name = "jgit_lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "7248b0a7d7f76dd4f7e55ed081b981cf4d8aa26e",
-        src_sha1 = "6ed203c95decc3f795f44ca17149e7554b92212d",
+        sha1 = "69d8510b335d4d33d551a133505a4141311f970a",
+        src_sha1 = "6fd1eb331447b6163898b4d10aa769e2ac193740",
         unsign = True,
     )
     maven_jar(
         name = "jgit_servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "f21fc0c651cc9475db92061432d919ba28b7a7ad",
+        sha1 = "93fb0075988b9c6bb97c725c03706f2341965b6b",
         unsign = True,
     )
     maven_jar(
         name = "jgit_archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "0f179321f527840dfc8ca79894eba2f6b255dbab",
+        sha1 = "a15aee805c758516ad7e9fa3f16e27bb9f4a1c2e",
     )
     maven_jar(
         name = "jgit_junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "7e5225064cf14115bddaae9448246c83c89f78ad",
+        sha1 = "b6e712e743ea5798134f54547ae80456fad07f76",
         unsign = True,
     )
 
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
index c424808..62870df 100644
--- a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
@@ -69,6 +69,7 @@
       element = fixture('basic');
       element.detailType = 'branches';
       counter = 0;
+      sandbox.stub(page, 'show');
     });
 
     teardown(() => {
@@ -314,6 +315,7 @@
       element = fixture('basic');
       element.detailType = 'tags';
       counter = 0;
+      sandbox.stub(page, 'show');
     });
 
     teardown(() => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index f04b7c6..c8f606b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-user-header/gr-user-header.html">
 
 <dom-module id="gr-dashboard-view">
   <template>
@@ -34,6 +35,9 @@
       gr-change-list {
         width: 100%;
       }
+      .hide {
+        display: none;
+      }
       @media only screen and (max-width: 50em) {
         .loading {
           padding: 0 var(--default-horizontal-margin);
@@ -42,6 +46,9 @@
     </style>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
+      <gr-user-header
+          user-id="[[params.user]]"
+          class$="[[_computeUserHeaderClass(params.user)]]"></gr-user-header>
       <gr-change-list
           show-star
           show-reviewed-state
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 750db92..0d56ac8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -53,6 +53,8 @@
       },
       /** @type {{ selectedChangeIndex: number }} */
       viewState: Object,
+
+      /** @type {{ user: string }} */
       params: {
         type: Object,
       },
@@ -144,5 +146,8 @@
       return query.replace(/\$\{user\}/g, user);
     },
 
+    _computeUserHeaderClass(userParam) {
+      return userParam === 'self' ? 'hide' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index 32d1143..cdbb756 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../diff/gr-patch-range-select/gr-patch-range-select.html">
+<link rel="import" href="../../edit/gr-edit-controls/gr-edit-controls.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
@@ -74,6 +75,7 @@
         display: none;
       }
       #diffPrefsContainer,
+      #editControlsContainer,
       .rightControls {
         align-self: flex-end;
         margin: auto 0 auto auto;
@@ -112,7 +114,7 @@
       .separator {
         background-color: rgba(0, 0, 0, .3);
         height: 1.5em;
-        margin: 0 .4em;
+        margin: 0 .6em;
         width: 1px;
       }
       .separator.transparent {
@@ -186,6 +188,9 @@
               class="prefsButton desktop"
               on-tap="_handlePrefsTap">Diff Preferences</gr-button>
         </span>
+        <span id="editControlsContainer" class="showOnEdit">
+          <gr-edit-controls change="[[change]]"></gr-edit-controls>
+        </span>
       </div>
     </div>
     <div class="fileList-header">
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 0fa6f4d..ab494b4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -60,7 +60,7 @@
       .separator {
         background-color: rgba(0, 0, 0, .3);
         height: 1.5em;
-        margin: 0 .4em;
+        margin: 0 .6em;
         width: 1px;
       }
       .separator.transparent {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index e95bd41..1bea61a 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -62,7 +62,7 @@
     },
 
     _getLinks(switchAccountUrl, path) {
-      const links = [{name: 'Settings', url: '/settings'}];
+      const links = [{name: 'Settings', url: '/settings/'}];
       if (switchAccountUrl) {
         const replacements = {path};
         const url = this._interpolateUrl(switchAccountUrl, replacements);
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 708cb17..474d0e6 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -87,8 +87,7 @@
     PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
     PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
 
-    QUERY: '/q/:query',
-    QUERY_OFFSET: '/q/:query,:offset',
+    QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
 
     // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
     CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
@@ -227,10 +226,14 @@
       } else if (params.view === Views.CHANGE) {
         let range = this._getPatchRangeExpression(params);
         if (range.length) { range = '/' + range; }
+        let suffix = `${range}`;
+        if (params.querystring) {
+          suffix += '?' + params.querystring;
+        }
         if (params.project) {
-          url = `/c/${params.project}/+/${params.changeNum}${range}`;
+          url = `/c/${params.project}/+/${params.changeNum}${suffix}`;
         } else {
-          url = `/c/${params.changeNum}${range}`;
+          url = `/c/${params.changeNum}${suffix}`;
         }
       } else if (params.view === Views.DASHBOARD) {
         if (params.sections) {
@@ -542,7 +545,6 @@
       this._mapRoute(RoutePattern.ADMIN_PLACEHOLDER,
           '_handleAdminPlaceholderRoute', true);
 
-      this._mapRoute(RoutePattern.QUERY_OFFSET, '_handleQueryRoute');
       this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
 
       this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
@@ -949,8 +951,11 @@
     },
 
     _handleQueryRoute(data) {
-      data.params.view = Gerrit.Nav.View.SEARCH;
-      this._setParams(data.params);
+      this._setParams({
+        view: Gerrit.Nav.View.SEARCH,
+        query: data.params[0],
+        offset: data.params[2],
+      });
     },
 
     _handleChangeNumberLegacyRoute(ctx) {
@@ -988,6 +993,7 @@
         basePatchNum: ctx.params[3],
         patchNum: ctx.params[5],
         view: Gerrit.Nav.View.CHANGE,
+        querystring: ctx.querystring,
       };
 
       this._normalizeLegacyRouteParams(params);
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 4aae65f..b4fec5b 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
@@ -237,13 +237,28 @@
           changeNum: '1234',
           project: 'test',
         };
+        const paramsWithQuery = {
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum: '1234',
+          project: 'test',
+          querystring: 'revert&foo=bar',
+        };
+
         assert.equal(element._generateUrl(params), '/c/test/+/1234');
+        assert.equal(element._generateUrl(paramsWithQuery),
+            '/c/test/+/1234?revert&foo=bar');
 
         params.patchNum = 10;
         assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
+        paramsWithQuery.patchNum = 10;
+        assert.equal(element._generateUrl(paramsWithQuery),
+            '/c/test/+/1234/10?revert&foo=bar');
 
         params.basePatchNum = 5;
         assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
+        paramsWithQuery.basePatchNum = 5;
+        assert.equal(element._generateUrl(paramsWithQuery),
+            '/c/test/+/1234/5..10?revert&foo=bar');
       });
 
       test('diff', () => {
@@ -531,6 +546,22 @@
             '/c/test/+/42#foo');
       });
 
+      test('_handleQueryRoute', () => {
+        const data = {params: ['project:foo/bar/baz']};
+        assertDataToParams(data, '_handleQueryRoute', {
+          view: Gerrit.Nav.View.SEARCH,
+          query: 'project:foo/bar/baz',
+          offset: undefined,
+        });
+
+        data.params.push(',123', '123');
+        assertDataToParams(data, '_handleQueryRoute', {
+          view: Gerrit.Nav.View.SEARCH,
+          query: 'project:foo/bar/baz',
+          offset: '123',
+        });
+      });
+
       suite('_handleRegisterRoute', () => {
         test('happy path', () => {
           const ctx = {params: ['/foo/bar']};
@@ -1103,6 +1134,7 @@
               null, // 4 Unused
               9, // 5 Patch number
             ],
+            querystring: '',
           };
           element._handleChangeLegacyRoute(ctx);
           assert.isTrue(normalizeRouteStub.calledOnce);
@@ -1111,6 +1143,7 @@
             basePatchNum: 6,
             patchNum: 9,
             view: Gerrit.Nav.View.CHANGE,
+            querystring: '',
           });
         });
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 3605e1d..f811e9e 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -51,6 +51,7 @@
     'is:open',
     'is:owner',
     'is:pending',
+    'is:private',
     'is:reviewed',
     'is:reviewer',
     'is:starred',
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index 95a6167..f532e3f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -32,8 +32,17 @@
         max-width: 15em;
       }
       .arrow {
+        color: rgba(0,0,0,.7);
         margin: 0 .5em;
       }
+      gr-dropdown-list {
+        --trigger-style: {
+          color: rgba(0,0,0,.7);
+          text-transform: none;
+          font-family: var(--font-family);
+        }
+        --trigger-hover-color: rgba(0,0,0,.6);
+      }
       @media screen and (max-width: 50em) {
         .filesWeblinks {
           display: none;
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 5eef391..0b2985f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -96,8 +96,9 @@
         dropdownContent.push({
           disabled: this._computeRightDisabled(patchNum, basePatchNum,
               _sortedRevisions),
-          triggerText: `Patchset ${patchNum}`,
-          text: `Patchset ${patchNum}` +
+          triggerText: `${patchNum === 'edit' ? '': 'Patchset '}` +
+              patchNum,
+          text: `${patchNum === 'edit' ? '': 'Patchset '}${patchNum}` +
               `${this._computePatchSetCommentsString(
                   this.comments, patchNum)}`,
           mobileText: this._computeMobileText(patchNum, this.comments,
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index 8ba3709..682956a 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -246,8 +246,8 @@
         },
         {
           disabled: false,
-          triggerText: 'Patchset edit',
-          text: 'Patchset edit',
+          triggerText: 'edit',
+          text: 'edit',
           mobileText: 'edit',
           bottomText: '',
           value: 'edit',
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
new file mode 100644
index 0000000..7ddf9a9
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.html
@@ -0,0 +1,86 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../bower_components/paper-input/paper-input.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-edit-controls">
+  <template>
+    <style include="shared-styles">
+      :host {
+        align-items: center;
+        display: flex;
+        justify-content: flex-end;
+      }
+      .invisible {
+        display: none;
+      }
+      gr-button {
+        margin-left: 1em;
+        text-decoration: none;
+      }
+      paper-input {
+        --paper-input-container: {
+          padding: 0;
+          min-width: 15em;
+        }
+        --paper-input-container-input: {
+          font-size: 1em;
+        }
+      }
+      gr-confirm-dialog {
+        width: 50em;
+      }
+      gr-confirm-dialog .main {
+        width: 100%;
+      }
+    </style>
+    <template is="dom-repeat" items="[[_actions]]" as="action">
+      <gr-button
+          id$="[[action.key]]"
+          link
+          on-tap="_handleTap">[[action.label]]</gr-button>
+    </template>
+    <gr-overlay id="overlay" with-backdrop>
+      <gr-confirm-dialog
+          id="editDialog"
+          class="invisible"
+          disabled$="[[!_isValidPath(_path)]]"
+          confirm-label="Edit"
+          on-confirm="_handleEditConfirm"
+          on-cancel="_handleDialogCancel">
+        <div class="header">Edit a file</div>
+        <div class="main">
+          <!-- TODO(kaspern): Make this an autocomplete. -->
+          <paper-input
+              class="input"
+              label="Enter an existing or new full file path."
+              value="{{_path}}"></paper-input>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+  </template>
+  <script src="gr-edit-controls.js"></script>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
new file mode 100644
index 0000000..62e8c7a
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
@@ -0,0 +1,91 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const Actions = {
+    EDIT: {label: 'Edit', key: 'edit'},
+    /* TODO(kaspern): Implement these actions.
+    DELETE: {label: 'Delete', key: 'delete'},
+    RENAME: {label: 'Rename', key: 'rename'},
+    REVERT: {label: 'Revert', key: 'revert'},
+    CHECKOUT: {label: 'Check out', key: 'checkout'},
+    */
+  };
+
+  Polymer({
+    is: 'gr-edit-controls',
+    properties: {
+      change: Object,
+
+      _actions: {
+        type: Array,
+        value() { return Object.values(Actions); },
+      },
+      _path: {
+        type: String,
+        value: '',
+      },
+    },
+
+    _handleTap(e) {
+      e.preventDefault();
+      const action = Polymer.dom(e).localTarget.id;
+      // TODO(kaspern): Add all actions to this switch.
+      switch (action) {
+        case Actions.EDIT.key:
+          this.openEditDialog();
+          return;
+      }
+    },
+
+    openEditDialog(opt_path) {
+      if (opt_path) { this._path = opt_path; }
+      return this._showDialog(this.$.editDialog);
+    },
+
+    /**
+     * Given a path string, checks that it is a valid file path.
+     * @param {string} path
+     * @return {boolean}
+     */
+    _isValidPath(path) {
+      return path.length && !path.endsWith('/');
+    },
+
+    _showDialog(dialog) {
+      return this.$.overlay.open().then(() => {
+        dialog.classList.toggle('invisible', false);
+        dialog.querySelector('.input').focus();
+        this.async(() => { this.$.overlay.center(); }, 1);
+      });
+    },
+
+    _closeDialog(dialog) {
+      dialog.querySelectorAll('.input').forEach(input => { input.value = ''; });
+      dialog.classList.toggle('invisible', true);
+      return this.$.overlay.close();
+    },
+
+    _handleDialogCancel(e) {
+      this._closeDialog(Polymer.dom(e).localTarget);
+    },
+
+    _handleEditConfirm(e) {
+      const url = Gerrit.Nav.getEditUrlForDiff(this.change, this._path);
+      Gerrit.Nav.navigateToRelativeUrl(url);
+      this._closeDialog(Polymer.dom(e).localTarget);
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
new file mode 100644
index 0000000..6da4e32
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.html
@@ -0,0 +1,100 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-edit-controls</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-edit-controls.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-edit-controls></gr-edit-controls>
+  </template>
+</test-fixture>
+
+<script>
+suite('gr-edit-controls tests', () => {
+  let element;
+  let sandbox;
+  let showDialogSpy;
+  let closeDialogSpy;
+
+  setup(() => {
+    sandbox = sinon.sandbox.create();
+    element = fixture('basic');
+    showDialogSpy = sandbox.spy(element, '_showDialog');
+    closeDialogSpy = sandbox.spy(element, '_closeDialog');
+    flushAsynchronousOperations();
+  });
+
+  teardown(() => { sandbox.restore(); });
+
+  test('all actions exist', () => {
+    assert.equal(Polymer.dom(element.root).querySelectorAll('gr-button').length,
+        1);
+  });
+
+  suite('edit button CUJ', () => {
+    let navStubs;
+
+    setup(() => {
+      navStubs = [
+        sandbox.stub(Gerrit.Nav, 'getEditUrlForDiff'),
+        sandbox.stub(Gerrit.Nav, 'navigateToRelativeUrl'),
+      ];
+    });
+
+    test('edit', () => {
+      MockInteractions.tap(element.$$('#edit'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.editDialog.disabled);
+        element._path = 'src/test.cpp';
+        assert.isFalse(element.$.editDialog.disabled);
+        MockInteractions.tap(element.$.editDialog.$$('gr-button[primary]'));
+        for (const stub of navStubs) { assert.isTrue(stub.called); }
+        assert.isTrue(closeDialogSpy.called);
+      });
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(element.$$('#edit'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.editDialog.disabled);
+        element._path = 'src/test.cpp';
+        assert.isFalse(element.$.editDialog.disabled);
+        MockInteractions.tap(element.$.editDialog.$$('gr-button'));
+        for (const stub of navStubs) { assert.isFalse(stub.called); }
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, '');
+      });
+    });
+  });
+
+  test('openEditDialog', () => {
+    return element.openEditDialog('test/path.cpp').then(() => {
+      assert.isFalse(element.$.editDialog.hasAttribute('hidden'));
+      assert.equal(element.$.editDialog.querySelector('.input').value,
+          'test/path.cpp');
+    });
+  });
+});
+</script>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
index dfaa6eb..c665df4 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
@@ -44,7 +44,9 @@
           <template is="dom-repeat" items="[[_agreements]]">
             <tr>
               <td class="nameColumn">
-                <a href$="[[getUrlBase(item.url)]]">[[item.name]]</a>
+                <a href$="[[getUrlBase(item.url)]]" rel="external">
+                  [[item.name]]
+                </a>
               </td>
               <td class="descriptionColumn">[[item.description]]</td>
             </tr>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
index c1c0338..83fb7cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -25,11 +25,11 @@
 <dom-module id="gr-autocomplete-dropdown">
   <template>
     <style include="shared-styles">
-      /* This must be set here vs. the container component because in some cases
-      the element is moved in the DOM to a base element and is no longer a
-      child of its original parent. */
-      :host(.fixed){
-        position: fixed;
+      :host {
+        z-index: 100;
+      }
+      :host([is-hidden]) {
+        display: none;
       }
       ul {
         list-style: none;
@@ -49,29 +49,22 @@
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
       }
     </style>
-    <iron-dropdown
-        id="dropdown"
-        allow-outside-scroll="true"
-        vertical-align="top"
-        horizontal-align="auto"
-        vertical-offset="[[verticalOffset]]">
-      <div
-          class="dropdown-content"
-          slot="dropdown-content"
-          id="suggestions"
-          role="listbox">
-        <ul>
-          <template is="dom-repeat" items="[[suggestions]]">
-            <li data-index$="[[index]]"
-                data-value$="[[item.dataValue]]"
-                tabindex="-1"
-                aria-label$="[[item.name]]"
-                role="option"
-                on-tap="_handleTapItem">[[item.text]]</li>
-          </template>
-        </ul>
-      </div>
-    </iron-dropdown>
+    <div
+        class="dropdown-content"
+        slot="dropdown-content"
+        id="suggestions"
+        role="listbox">
+      <ul>
+        <template is="dom-repeat" items="[[suggestions]]">
+          <li data-index$="[[index]]"
+              data-value$="[[item.dataValue]]"
+              tabindex="-1"
+              aria-label$="[[item.name]]"
+              role="option"
+              on-tap="_handleTapItem">[[item.text]]</li>
+        </template>
+      </ul>
+    </div>
     <gr-cursor-manager
         id="cursor"
         index="{{index}}"
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
index 100b5ca..c0449be 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -14,9 +14,6 @@
 (function() {
   'use strict';
 
-  const AWAIT_MAX_ITERS = 10;
-  const AWAIT_STEP = 5;
-
   Polymer({
     is: 'gr-autocomplete-dropdown',
 
@@ -34,6 +31,11 @@
 
     properties: {
       index: Number,
+      isHidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
       verticalOffset: {
         type: Number,
         value: null,
@@ -53,6 +55,7 @@
     },
 
     behaviors: [
+      Polymer.IronFitBehavior,
       Gerrit.KeyboardShortcutBehavior,
     ],
 
@@ -65,46 +68,14 @@
     },
 
     close() {
-      this.$.dropdown.close();
+      this.isHidden = true;
     },
 
     open() {
-      this._open().then(() => {
-        this._resetCursorStops();
-        this._resetCursorIndex();
-        this.fire('open-complete');
-      });
-    },
-
-    // TODO (beckysiegel) look into making this a behavior since it's used
-    // 3 times now.
-    _open(...args) {
-      return new Promise(resolve => {
-        Polymer.IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
-        this._awaitOpen(resolve);
-      });
-    },
-
-    /**
-     * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
-     * opening. Eventually replace with a direct way to listen to the overlay.
-     */
-    _awaitOpen(fn) {
-      let iters = 0;
-      const step = () => {
-        this.async(() => {
-          if (this.style.display !== 'none') {
-            fn.call(this);
-          } else if (iters++ < AWAIT_MAX_ITERS) {
-            step.call(this);
-          }
-        }, AWAIT_STEP);
-      };
-      step.call(this);
-    },
-
-    get isHidden() {
-      return !this.$.dropdown.opened;
+      this.isHidden = false;
+      this.refit();
+      this._resetCursorStops();
+      this._resetCursorIndex();
     },
 
     getCurrentText() {
@@ -112,7 +83,7 @@
     },
 
     _handleUp(e) {
-      if (!this.hidden) {
+      if (!this.isHidden) {
         e.preventDefault();
         e.stopPropagation();
         this.cursorUp();
@@ -120,7 +91,7 @@
     },
 
     _handleDown(e) {
-      if (!this.hidden) {
+      if (!this.isHidden) {
         e.preventDefault();
         e.stopPropagation();
         this.cursorDown();
@@ -128,13 +99,13 @@
     },
 
     cursorDown() {
-      if (!this.hidden) {
+      if (!this.isHidden) {
         this.$.cursor.next();
       }
     },
 
     cursorUp() {
-      if (!this.hidden) {
+      if (!this.isHidden) {
         this.$.cursor.previous();
       }
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
index 23b27be..1995688 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
@@ -52,7 +52,7 @@
     });
 
     test('escape key', done => {
-      const closeSpy = sandbox.spy(element.$.dropdown, 'close');
+      const closeSpy = sandbox.spy(element, 'close');
       MockInteractions.pressAndReleaseKeyOn(element, 27);
       flushAsynchronousOperations();
       assert.isTrue(closeSpy.called);
@@ -87,24 +87,24 @@
     });
 
     test('down key', () => {
-      element.hidden = true;
+      element.isHidden = true;
       const nextSpy = sandbox.spy(element.$.cursor, 'next');
       MockInteractions.pressAndReleaseKeyOn(element, 40);
       assert.isFalse(nextSpy.called);
       assert.equal(element.$.cursor.index, 0);
-      element.hidden = false;
+      element.isHidden = false;
       MockInteractions.pressAndReleaseKeyOn(element, 40);
       assert.isTrue(nextSpy.called);
       assert.equal(element.$.cursor.index, 1);
     });
 
     test('up key', () => {
-      element.hidden = true;
+      element.isHidden = true;
       const prevSpy = sandbox.spy(element.$.cursor, 'previous');
       MockInteractions.pressAndReleaseKeyOn(element, 38);
       assert.isFalse(prevSpy.called);
       assert.equal(element.$.cursor.index, 0);
-      element.hidden = false;
+      element.isHidden = false;
       element.$.cursor.setCursorAtIndex(1);
       assert.equal(element.$.cursor.index, 1);
       MockInteractions.pressAndReleaseKeyOn(element, 38);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index 81ac90e..7aa7abf 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -38,26 +38,29 @@
         color: red;
       }
     </style>
-    <input
-        id="input"
-        class$="[[_computeClass(borderless)]]"
-        is="iron-input"
-        disabled$="[[disabled]]"
-        bind-value="{{text}}"
-        placeholder="[[placeholder]]"
-        on-keydown="_handleKeydown"
-        on-focus="_onInputFocus"
-        on-blur="_onInputBlur"
-        autocomplete="off"/>
-    <!-- This container is needed for Safari and Firefox -->
-    <div id="suggestionContainer">
+    <div>
+      <input
+          id="input"
+          class$="[[_computeClass(borderless)]]"
+          is="iron-input"
+          disabled$="[[disabled]]"
+          bind-value="{{text}}"
+          placeholder="[[placeholder]]"
+          on-keydown="_handleKeydown"
+          on-focus="_onInputFocus"
+          on-blur="_onInputBlur"
+          autocomplete="off"/>
       <gr-autocomplete-dropdown
+          vertical-align="top"
+          vertical-offset="20"
+          horizontal-align="auto"
           id="suggestions"
           on-item-selected="_handleItemSelect"
           on-keydown="_handleKeydown"
           suggestions="[[_suggestions]]"
           role="listbox"
-          index="[[_index]]">
+          index="[[_index]]"
+          position-target="[[_inputElement]]">
       </gr-autocomplete-dropdown>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index aa20f96..18c811d 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -185,6 +185,10 @@
       this._commit();
     },
 
+    get _inputElement() {
+      return this.$.input;
+    },
+
     /**
      * Set the text of the input without triggering the suggestion dropdown.
      * @param {string} text The new text for the input.
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
index 91c2def..0916a89 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -72,10 +72,6 @@
           background-color: #f2f2f2;
         }
       }
-      gr-button {
-        --gr-button-arrow-color: var(--color-link);
-        --gr-button-arrow-hover-color: var(--color-link-hover);
-      }
       paper-item:not(:last-of-type) {
         border-bottom: 1px solid #ddd;
       }
@@ -95,6 +91,12 @@
         flex-direction: row;
         width: 100%;
       }
+       gr-button {
+        --gr-button: {
+          @apply --trigger-style;
+        }
+        --gr-button-hover-color: var(--trigger-hover-color);
+      }
       gr-date-formatter {
         color: rgba(0,0,0,.54);
         margin-left: 2em;
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
index 95df7c9..3c28674 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -67,12 +67,19 @@
       }
     </style>
     <div id="hiddenText"></div>
+    <!-- When the autocomplete is open, the span is moved at the end of
+      hiddenText in order to correctly position the dropdown. After being moved,
+      it is set as the positionTarget for the emojiSuggestions dropdown. -->
+    <span id="caratSpan"></span>
     <gr-autocomplete-dropdown
+        vertical-align="top"
+        horizontal-align="left"
+        dynamic-align
         id="emojiSuggestions"
         suggestions="[[_suggestions]]"
         index="[[_index]]"
         vertical-offset="[[_verticalOffset]]"
-        on-dropdown-closed="_resetAndFocus"
+        on-dropdown-closed="_resetEmojiDropdown"
         on-item-selected="_handleEmojiSelect">
     </gr-autocomplete-dropdown>
     <iron-autogrow-textarea
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
index 2d796bd..519db55 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -125,7 +125,6 @@
       }
     },
 
-
     closeDropdown() {
       return this.$.emojiSuggestions.close();
     },
@@ -148,10 +147,6 @@
       if (this._hideAutocomplete) { return; }
       e.preventDefault();
       e.stopPropagation();
-      this._resetAndFocus();
-    },
-
-    _resetAndFocus() {
       this._resetEmojiDropdown();
     },
 
@@ -190,15 +185,19 @@
     },
     /**
      * Uses a hidden element with the same width and styling of the textarea and
-     * the text up until the point of interest. Then the emoji selection
-     * element is added to the end so that they are correctly positioned by the
-     * end of the last character entered.
+     * the text up until the point of interest. Then caratSpan element is added
+     * to the end and is set to be the positionTarget for the dropdown. Together
+     * this allows the dropdown to appear near where the user is typing.
      */
     _updateCaratPosition() {
+      this._hideAutocomplete = false;
       this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
           this.$.textarea.selectionStart);
 
-      this.$.hiddenText.appendChild(this.$.emojiSuggestions);
+      const caratSpan = this.$.caratSpan;
+      this.$.hiddenText.appendChild(caratSpan);
+      this.$.emojiSuggestions.positionTarget = caratSpan;
+      this._openEmojiDropdown();
     },
 
     _getFontSize() {
@@ -250,8 +249,6 @@
         // Otherwise open the dropdown and set the position to be just below the
         // cursor.
         } else if (this.$.emojiSuggestions.isHidden) {
-          this._hideAutocomplete = false;
-          this._openEmojiDropdown();
           this._updateCaratPosition();
         }
         this.$.textarea.textarea.focus();
@@ -268,7 +265,7 @@
         suggestion.text = suggestion.value + ' ' + suggestion.match;
         suggestions.push(suggestion);
       }
-      this._suggestions = suggestions;
+      this.set('_suggestions', suggestions);
     },
 
     _determineSuggestions(emojiText) {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
index 493dd5d..0434865 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -171,11 +171,11 @@
       element.text = 'test';
       element._updateCaratPosition();
       assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
-          element.$.emojiSuggestions.outerHTML);
+          element.$.caratSpan.outerHTML);
     });
 
     test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
-      const resetSpy = sandbox.spy(element, '_resetAndFocus');
+      const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
       element.$.emojiSuggestions.fire('dropdown-closed');
       assert.isTrue(resetSpy.called);
     });
@@ -190,10 +190,6 @@
 
     suite('keyboard shortcuts', () => {
       function setupDropdown(callback) {
-        element.$.emojiSuggestions.addEventListener('open-complete', () => {
-          callback();
-        });
-        flushAsynchronousOperations();
         MockInteractions.focus(element.$.textarea);
         element.$.textarea.selectionStart = 1;
         element.$.textarea.selectionEnd = 1;
@@ -201,55 +197,48 @@
         element.$.textarea.selectionStart = 1;
         element.$.textarea.selectionEnd = 2;
         element.text = ':1';
+        flushAsynchronousOperations();
       }
 
-      test('escape key', done => {
-        const resestSpy = sandbox.spy(element, '_resetAndFocus');
+      test('escape key', () => {
+        const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
         MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-        assert.isFalse(resestSpy.called);
-        setupDropdown(() => {
-          MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-          assert.isTrue(resestSpy.called);
-          assert.isFalse(!element.$.emojiSuggestions.isHidden);
-          done();
-        });
+        assert.isFalse(resetSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+        assert.isTrue(resetSpy.called);
+        assert.isFalse(!element.$.emojiSuggestions.isHidden);
       });
 
-      test('up key', done => {
+      test('up key', () => {
         const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
         MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
         assert.isFalse(upSpy.called);
-        setupDropdown(() => {
-          MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-          assert.isTrue(upSpy.called);
-          done();
-        });
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+        assert.isTrue(upSpy.called);
       });
 
-      test('down key', done => {
+      test('down key', () => {
         const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
         MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
         assert.isFalse(downSpy.called);
-        setupDropdown(() => {
-          MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-          assert.isTrue(downSpy.called);
-          done();
-        });
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+        assert.isTrue(downSpy.called);
       });
 
-      test('enter key', done => {
+      test('enter key', () => {
         const enterSpy = sandbox.spy(element.$.emojiSuggestions,
             'getCursorTarget');
         MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
         assert.isFalse(enterSpy.called);
-        setupDropdown(() => {
-          MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-          assert.isTrue(enterSpy.called);
-          flushAsynchronousOperations();
-          // A space is automatically added at the end.
-          assert.equal(element.text, '💯 ');
-          done();
-        });
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isTrue(enterSpy.called);
+        flushAsynchronousOperations();
+        // A space is automatically added at the end.
+        assert.equal(element.text, '💯 ');
       });
     });
   });
diff --git a/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py b/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
index b128d65..2f6b030 100644
--- a/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
+++ b/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
@@ -89,6 +89,7 @@
   # Special case for polymer behaviors we are using.
   replaceBehaviorLikeHTML("polygerrit-ui/app/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html", "iron-a11y-keys-behavior.html")
   generateStubBehavior("Polymer.IronOverlayBehavior")
+  generateStubBehavior("Polymer.IronFitBehavior")
 
   #TODO figure out something to do with iron-overlay-behavior. it is hard-coded reformatted.
 
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index b0310c6..7cc37d8 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -103,6 +103,7 @@
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
     'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
+    'edit/gr-edit-controls/gr-edit-controls_test.html',
     'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
     'edit/gr-editor-view/gr-editor-view_test.html',
     'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
diff --git a/version.bzl b/version.bzl
index 62d841f..9a97721 100644
--- a/version.bzl
+++ b/version.bzl
@@ -3,3 +3,7 @@
 # when talking to the destination repository.
 #
 GERRIT_VERSION = "2.16-SNAPSHOT"
+
+def check_version(x):
+    if native.bazel_version < x:
+        fail("\nERROR: Current Bazel version is {}, expected at least {}\n".format(native.bazel_version, x))